Do add_defaults() always at the last minute before calling into the cscript.
[cdist.git] / cdist
1 #!/usr/bin/python3
2
3 #    Copyright (C) 2012-2022 Carl Hetherington <cth@carlh.net>
4 #
5 #    This program is free software; you can redistribute it and/or modify
6 #    it under the terms of the GNU General Public License as published by
7 #    the Free Software Foundation; either version 2 of the License, or
8 #    (at your option) any later version.
9 #
10 #    This program is distributed in the hope that it will be useful,
11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #    GNU General Public License for more details.
14
15 #    You should have received a copy of the GNU General Public License
16 #    along with this program; if not, write to the Free Software
17 #    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
18
19 from __future__ import print_function
20
21 import argparse
22 import copy
23 import datetime
24 import getpass
25 import glob
26 import inspect
27 import multiprocessing
28 import os
29 from pathlib import Path
30 import platform
31 import re
32 import signal
33 import shlex
34 import shutil
35 import subprocess
36 import sys
37 import tempfile
38 import time
39
40 class Error(Exception):
41     def __init__(self, value):
42         self.value = value
43     def __str__(self):
44         return self.value
45     def __repr__(self):
46         return str(self)
47
48 class Trees:
49     """
50     Store for Tree objects which re-uses already-created objects
51     and checks for requests for different versions of the same thing.
52     """
53
54     def __init__(self):
55         self.trees = []
56
57     def get(self, name, commit_ish, target, required_by=None):
58         for t in self.trees:
59             if t.name == name and t.commit_ish == commit_ish and t.target == target:
60                 return t
61             elif t.name == name and t.commit_ish != commit_ish:
62                 a = commit_ish if commit_ish is not None else "[Any]"
63                 if required_by is not None:
64                     a += ' by %s' % required_by
65                 b = t.commit_ish if t.commit_ish is not None else "[Any]"
66                 if t.required_by is not None:
67                     b += ' by %s' % t.required_by
68                 raise Error('conflicting versions of %s required (%s versus %s)' % (name, a, b))
69
70         nt = Tree(name, commit_ish, target, required_by)
71         self.trees.append(nt)
72         return nt
73
74     def add_built(self, name, commit_ish, target):
75         self.trees.append(Tree(name, commit_ish, target, None, built=True))
76
77
78 class Globals:
79     quiet = False
80     command = None
81     dry_run = False
82     use_git_reference = True
83     trees = Trees()
84
85 globals = Globals()
86
87
88 #
89 # Configuration
90 #
91
92 class Option:
93     def __init__(self, key, default=None):
94         self.key = key
95         self.value = default
96
97     def offer(self, key, value):
98         if key == self.key:
99             self.value = value
100
101 class BoolOption:
102     def __init__(self, key):
103         self.key = key
104         self.value = False
105
106     def offer(self, key, value):
107         if key == self.key:
108             self.value = value in ['yes', '1', 'true']
109
110 class Config:
111     def __init__(self):
112         self.options = [ Option('mxe_prefix'),
113                          Option('git_prefix'),
114                          Option('git_reference'),
115                          Option('osx_environment_prefix'),
116                          Option('osx_sdk_prefix'),
117                          Option('osx_sdk'),
118                          Option('osx_intel_deployment'),
119                          Option('osx_arm_deployment'),
120                          Option('osx_old_deployment'),
121                          Option('osx_keychain_file'),
122                          Option('osx_keychain_password'),
123                          Option('apple_id'),
124                          Option('apple_password'),
125                          Option('apple_team_id'),
126                          BoolOption('docker_sudo'),
127                          BoolOption('docker_no_user'),
128                          Option('docker_hub_repository'),
129                          Option('flatpak_state_dir'),
130                          Option('parallel', multiprocessing.cpu_count()),
131                          Option('temp', '/var/tmp'),
132                          Option('osx_notarytool', ['xcrun', 'notarytool'])]
133
134         config_dir = '%s/.config' % os.path.expanduser('~')
135         if not os.path.exists(config_dir):
136             os.mkdir(config_dir)
137         config_file = '%s/cdist' % config_dir
138         if not os.path.exists(config_file):
139             f = open(config_file, 'w')
140             for o in self.options:
141                 print('# %s ' % o.key, file=f)
142             f.close()
143             print('Template config file written to %s; please edit and try again.' % config_file, file=sys.stderr)
144             sys.exit(1)
145
146         f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
147         while True:
148             l = f.readline()
149             if l == '':
150                 break
151
152             if len(l) > 0 and l[0] == '#':
153                 continue
154
155             s = l.strip().split()
156             if len(s) == 2:
157                 for k in self.options:
158                     k.offer(s[0], s[1])
159
160         if not isinstance(self.get('osx_notarytool'), list):
161             self.set('osx_notarytool', [self.get('osx_notarytool')])
162
163
164     def has(self, k):
165         for o in self.options:
166             if o.key == k and o.value is not None:
167                 return True
168         return False
169
170     def get(self, k):
171         for o in self.options:
172             if o.key == k:
173                 if o.value is None:
174                     raise Error('Required setting %s not found' % k)
175                 return o.value
176
177     def set(self, k, v):
178         for o in self.options:
179             o.offer(k, v)
180
181     def docker(self):
182         if self.get('docker_sudo'):
183             return 'sudo docker'
184         else:
185             return 'docker'
186
187 config = Config()
188
189 #
190 # Utility bits
191 #
192
193 def log_normal(m):
194     if not globals.quiet:
195         print('\x1b[33m* %s\x1b[0m' % m)
196
197 def log_verbose(m):
198     if globals.verbose:
199         print('\x1b[35m* %s\x1b[0m' % m)
200
201 def escape_spaces(s):
202     return s.replace(' ', '\\ ')
203
204 def scp_escape(n):
205     """Escape a host:filename string for use with an scp command"""
206     s = n.split(':')
207     assert(len(s) == 1 or len(s) == 2)
208     if len(s) == 2:
209         return '%s:"\'%s\'"' % (s[0], s[1])
210     else:
211         return '\"%s\"' % s[0]
212
213 def mv_escape(n):
214     return '\"%s\"' % n.substr(' ', '\\ ')
215
216 def copytree(a, b):
217     log_normal('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
218     if b.startswith('s3://'):
219         command('s3cmd -P -r put "%s" "%s"' % (a, b))
220     else:
221         command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
222
223 def copyfile(a, b):
224     log_normal('copy %s -> %s with cwd %s' % (scp_escape(a), scp_escape(b), os.getcwd()))
225     if b.startswith('s3://'):
226         command('s3cmd -P put "%s" "%s"' % (a, b))
227     else:
228         bc = b.find(":")
229         if bc != -1:
230             host = b[:bc]
231             path = b[bc+1:]
232             temp_path = os.path.join(os.path.dirname(path), ".tmp." + os.path.basename(path))
233             command('scp %s %s' % (scp_escape(a), scp_escape(host + ":" + temp_path)))
234             command('ssh %s -- mv "%s" "%s"' % (host, escape_spaces(temp_path), escape_spaces(path)))
235         else:
236             command('scp %s %s' % (scp_escape(a), scp_escape(b)))
237
238 def makedirs(d):
239     """
240     Make directories either locally or on a remote host; remotely if
241     d includes a colon, otherwise locally.
242     """
243     if d.startswith('s3://'):
244         # No need to create folders on S3
245         return
246
247     if d.find(':') == -1:
248         try:
249             os.makedirs(d)
250         except OSError as e:
251             if e.errno != 17:
252                 raise e
253     else:
254         s = d.split(':')
255         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
256
257 def rmdir(a):
258     log_normal('remove %s' % a)
259     os.rmdir(a)
260
261 def rmtree(a):
262     log_normal('remove %s' % a)
263     shutil.rmtree(a, ignore_errors=True)
264
265 def command(c):
266     log_normal(c)
267     try:
268         r = subprocess.run(c, shell=True)
269         if r.returncode != 0:
270             raise Error('command %s failed (%d)' % (c, r.returncode))
271     except Exception as e:
272         raise Error('command %s failed (%s)' % (c, e))
273
274 def command_and_read(c):
275     log_normal(c)
276     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
277     (out, err) = p.communicate()
278     if p.returncode != 0:
279         raise Error('command %s failed (%s)' % (c, err))
280     return str(out, 'utf-8').splitlines()
281
282 def read_wscript_variable(directory, variable):
283     f = open('%s/wscript' % directory, 'r')
284     while True:
285         l = f.readline()
286         if l == '':
287             break
288
289         s = l.split()
290         if len(s) == 3 and s[0] == variable:
291             f.close()
292             return s[2][1:-1]
293
294     f.close()
295     return None
296
297
298 def devel_to_git(git_commit, filename):
299     if git_commit is not None:
300         filename = filename.replace('devel', '-%s' % git_commit)
301     return filename
302
303
304 def get_command_line_options(args):
305     """Get the options specified by --option on the command line"""
306     options = dict()
307     if args.option is not None:
308         for o in args.option:
309             b = o.split(':')
310             if len(b) != 2:
311                 raise Error("Bad option `%s'" % o)
312             if b[1] == 'False':
313                 options[b[0]] = False
314             elif b[1] == 'True':
315                 options[b[0]] = True
316             else:
317                 options[b[0]] = b[1]
318     return options
319
320
321 class TreeDirectory:
322     def __init__(self, tree):
323         self.tree = tree
324     def __enter__(self):
325         self.cwd = os.getcwd()
326         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
327     def __exit__(self, type, value, traceback):
328         os.chdir(self.cwd)
329
330 #
331 # Version
332 #
333
334 class Version:
335     def __init__(self, s):
336         self.devel = False
337
338         if s.startswith("'"):
339             s = s[1:]
340         if s.endswith("'"):
341             s = s[0:-1]
342
343         if s.endswith('devel'):
344             s = s[0:-5]
345             self.devel = True
346
347         if s.endswith('pre'):
348             s = s[0:-3]
349
350         p = s.split('.')
351         self.major = int(p[0])
352         self.minor = int(p[1])
353         if len(p) == 3:
354             self.micro = int(p[2])
355         else:
356             self.micro = 0
357
358     @classmethod
359     def from_git_tag(cls, tag):
360         bits = tag.split('-')
361         c = cls(bits[0])
362         if len(bits) > 1 and int(bits[1]) > 0:
363             c.devel = True
364         return c
365
366     def bump_minor(self):
367         self.minor += 1
368         self.micro = 0
369
370     def bump_micro(self):
371         self.micro += 1
372
373     def to_devel(self):
374         self.devel = True
375
376     def to_release(self):
377         self.devel = False
378
379     def __str__(self):
380         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
381         if self.devel:
382             s += 'devel'
383
384         return s
385
386 #
387 # Targets
388 #
389
390 class Target:
391     """
392     Class representing the target that we are building for.  This is exposed to cscripts,
393     though not all of it is guaranteed 'API'.  cscripts may expect:
394
395     platform: platform string (e.g. 'windows', 'linux', 'osx')
396     parallel: number of parallel jobs to run
397     directory: directory to work in
398     variables: dict of environment variables
399     debug: True to build a debug version, otherwise False
400     ccache: True to use ccache, False to not
401     set(a, b): set the value of variable 'a' to 'b'
402     unset(a): unset the value of variable 'a'
403     command(c): run the command 'c' in the build environment
404
405     """
406
407     def __init__(self, platform, directory=None, dependencies_only=False):
408         """
409         platform -- platform string (e.g. 'windows', 'linux', 'osx')
410         directory -- directory to work in; if None we will use a temporary directory
411         Temporary directories will be removed after use; specified directories will not.
412         """
413         self.platform = platform
414         self.parallel = int(config.get('parallel'))
415
416         # Environment variables that we will use when we call cscripts
417         self.variables = {}
418         self.debug = False
419         self._ccache = False
420         # True to build our dependencies ourselves; False if this is taken care
421         # of in some other way
422         self.build_dependencies = True
423         self.dependencies_only = dependencies_only
424
425         if directory is None:
426             try:
427                 os.makedirs(config.get('temp'))
428             except OSError as e:
429                 if e.errno != 17:
430                     raise e
431             self.directory = tempfile.mkdtemp('', 'tmp', config.get('temp'))
432             self.rmdir = True
433             self.set('CCACHE_BASEDIR', os.path.realpath(self.directory))
434             self.set('CCACHE_NOHASHDIR', '')
435         else:
436             self.directory = os.path.realpath(directory)
437             self.rmdir = False
438
439
440     def setup(self):
441         pass
442
443     def _cscript_package(self, tree, options):
444         """
445         Call package() in the cscript and return what it returns, except that
446         anything not in a list will be put into one.
447         """
448         if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
449             packages = tree.call('package', tree.version, tree.add_defaults(options))
450         else:
451             log_normal("Deprecated cscript package() method with no options parameter")
452             packages = tree.call('package', tree.version)
453
454         return packages if isinstance(packages, list) else [packages]
455
456     def _copy_packages(self, tree, packages, output_dir):
457         for p in packages:
458             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p))))
459
460     def package(self, project, checkout, output_dir, options, notarize):
461         tree = self.build(project, checkout, options, for_package=True)
462         p = self._cscript_package(tree, options)
463         self._copy_packages(tree, p, output_dir)
464
465     def build(self, project, checkout, options, for_package=False):
466         tree = globals.trees.get(project, checkout, self)
467         if self.build_dependencies:
468             tree.build_dependencies(options)
469         if not self.dependencies_only:
470             tree.build(options, for_package=for_package)
471         return tree
472
473     def test(self, project, checkout, target, test, options):
474         """test is the test case to run, or None"""
475         tree = globals.trees.get(project, checkout, target)
476
477         with TreeDirectory(tree):
478             if len(inspect.getfullargspec(tree.cscript['test']).args) == 3:
479                 return tree.call('test', tree.add_defaults(options), test)
480             else:
481                 log_normal('Deprecated cscript test() method with no options parameter')
482                 return tree.call('test', test)
483
484     def set(self, a, b):
485         self.variables[a] = b
486
487     def unset(self, a):
488         del(self.variables[a])
489
490     def get(self, a):
491         return self.variables[a]
492
493     def append(self, k, v, s):
494         if (not k in self.variables) or len(self.variables[k]) == 0:
495             self.variables[k] = '"%s"' % v
496         else:
497             e = self.variables[k]
498             if e[0] == '"' and e[-1] == '"':
499                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
500             else:
501                 self.variables[k] = '"%s%s%s"' % (e, s, v)
502
503     def append_with_space(self, k, v):
504         return self.append(k, v, ' ')
505
506     def append_with_colon(self, k, v):
507         return self.append(k, v, ':')
508
509     def variables_string(self, escaped_quotes=False):
510         e = ''
511         for k, v in self.variables.items():
512             if escaped_quotes:
513                 v = v.replace('"', '\\"')
514             e += '%s=%s ' % (k, v)
515         return e
516
517     def cleanup(self):
518         if self.rmdir:
519             rmtree(self.directory)
520
521     def mount(self, m):
522         pass
523
524     @property
525     def ccache(self):
526         return self._ccache
527
528     @ccache.setter
529     def ccache(self, v):
530         self._ccache = v
531
532
533 class DockerTarget(Target):
534     def __init__(self, platform, directory):
535         super(DockerTarget, self).__init__(platform, directory)
536         self.mounts = []
537         self.privileged = False
538
539     def _user_tag(self):
540         if config.get('docker_no_user'):
541             return ''
542         return '-u %s' % getpass.getuser()
543
544     def _mount_option(self, d):
545         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
546
547     def setup(self):
548         opts = self._mount_option(self.directory)
549         for m in self.mounts:
550             opts += self._mount_option(m)
551         if config.has('git_reference'):
552             opts += self._mount_option(config.get('git_reference'))
553         if self.privileged:
554             opts += '--privileged=true '
555         if self.ccache:
556             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache " % (self.image, os.getuid())
557         opts += "--rm "
558
559         tag = self.image
560         if config.has('docker_hub_repository'):
561             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
562
563         def signal_handler(signum, frame):
564             raise Error('Killed')
565         signal.signal(signal.SIGTERM, signal_handler)
566
567         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
568
569     def command(self, cmd):
570         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
571         interactive_flag = '-i ' if sys.stdin.isatty() else ''
572         command('%s exec %s %s -t %s /bin/bash -c \'export %s; cd %s; %s\'' % (config.docker(), self._user_tag(), interactive_flag, self.container, self.variables_string(), dir, cmd))
573
574     def cleanup(self):
575         super(DockerTarget, self).cleanup()
576         command('%s kill %s' % (config.docker(), self.container))
577
578     def mount(self, m):
579         self.mounts.append(m)
580
581
582 class WindowsDockerTarget(DockerTarget):
583     """
584     This target exposes the following additional API:
585
586     bits: bitness of Windows (32 or 64)
587     name: name of our target e.g. x86_64-w64-mingw32.shared
588     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
589     tool_path: path to 32- and 64-bit tools
590     """
591     def __init__(self, bits, directory, environment_version):
592         super(WindowsDockerTarget, self).__init__('windows', directory)
593         self.bits = bits
594         # This was used to differentiate "normal" Windows from XP, and is no longer important,
595         # but old cscripts still look for it
596         self.version = None
597
598         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
599         if self.bits == 32:
600             self.name = 'i686-w64-mingw32.shared'
601         else:
602             self.name = 'x86_64-w64-mingw32.shared'
603         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
604
605         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
606         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
607         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
608         self.set('LD', '%s-ld' % self.name)
609         self.set('RANLIB', '%s-ranlib' % self.name)
610         self.set('WINRC', '%s-windres' % self.name)
611         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
612         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
613         self.set('CXXFLAGS', '"%s"' % cxx)
614         self.set('CPPFLAGS', '')
615         self.set('LINKFLAGS', '"%s"' % link)
616         self.set('LDFLAGS', '"%s"' % link)
617
618         self.image = 'windows'
619         if environment_version is not None:
620             self.image += '_%s' % environment_version
621
622     def setup(self):
623         super().setup()
624         if self.ccache:
625             self.set('CC', '"ccache %s-gcc"' % self.name)
626             self.set('CXX', '"ccache %s-g++"' % self.name)
627         else:
628             self.set('CC', '%s-gcc' % self.name)
629             self.set('CXX', '%s-g++' % self.name)
630
631     @property
632     def library_prefix(self):
633         log_normal('Deprecated property library_prefix: use environment_prefix')
634         return self.environment_prefix
635
636     @property
637     def windows_prefix(self):
638         log_normal('Deprecated property windows_prefix: use environment_prefix')
639         return self.environment_prefix
640
641     @property
642     def mingw_prefixes(self):
643         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
644         return [self.environment_prefix]
645
646     @property
647     def mingw_path(self):
648         log_normal('Deprecated property mingw_path: use tool_path')
649         return self.tool_path
650
651     @property
652     def mingw_name(self):
653         log_normal('Deprecated property mingw_name: use name')
654         return self.name
655
656
657 class WindowsNativeTarget(Target):
658     """
659     This target exposes the following additional API:
660
661     bits: bitness of Windows (32 or 64)
662     name: name of our target e.g. x86_64-w64-mingw32.shared
663     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
664     """
665     def __init__(self, directory):
666         super().__init__('windows', directory)
667         self.bits = 64
668
669         self.environment_prefix = config.get('windows_native_environmnet_prefix')
670
671         self.set('PATH', '%s/bin:%s' % (self.environment_prefix, os.environ['PATH']))
672
673     def command(self, cmd):
674         command(cmd)
675
676
677 class LinuxTarget(DockerTarget):
678     """
679     Build for Linux in a docker container.
680     This target exposes the following additional API:
681
682     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
683     version: distribution version (e.g. '12.04', '8', '6.5')
684     bits: bitness of the distribution (32 or 64)
685     detail: None or 'appimage' if we are building for appimage
686     """
687
688     def __init__(self, distro, version, bits, directory=None):
689         super(LinuxTarget, self).__init__('linux', directory)
690         self.distro = distro
691         self.version = version
692         self.bits = bits
693         self.detail = None
694
695         self.set('CXXFLAGS', '-I%s/include' % self.directory)
696         self.set('CPPFLAGS', '')
697         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
698         self.set('PKG_CONFIG_PATH',
699                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
700         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
701
702         if self.version is None:
703             self.image = '%s-%s' % (self.distro, self.bits)
704         else:
705             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
706
707     def setup(self):
708         super(LinuxTarget, self).setup()
709         if self.ccache:
710             self.set('CC', '"ccache gcc"')
711             self.set('CXX', '"ccache g++"')
712
713     def test(self, project, checkout, target, test, options):
714         self.append_with_colon('PATH', '%s/bin' % self.directory)
715         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
716         super(LinuxTarget, self).test(project, checkout, target, test, options)
717
718
719 class AppImageTarget(LinuxTarget):
720     def __init__(self, work):
721         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
722         self.detail = 'appimage'
723         self.privileged = True
724
725
726 class FlatpakTarget(Target):
727     def __init__(self, project, checkout, work):
728         super(FlatpakTarget, self).__init__('flatpak')
729         self.build_dependencies = False
730         self.project = project
731         self.checkout = checkout
732         # If we use git references we end up with a checkout in one mount trying
733         # to link to the git reference repo in other, which doesn't work.
734         globals.use_git_reference = False
735         if config.has('flatpak_state_dir'):
736             self.mount(config.get('flatpak_state_dir'))
737
738     def command(self, c):
739         log_normal('host -> %s' % c)
740         command('%s %s' % (self.variables_string(), c))
741
742     def setup(self):
743         super().setup()
744         globals.trees.get(self.project, self.checkout, self).checkout_dependencies()
745
746     def flatpak(self):
747         return 'flatpak'
748
749     def flatpak_builder(self):
750         b = 'flatpak-builder'
751         if config.has('flatpak_state_dir'):
752             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
753         return b
754
755
756 def notarize_dmg(dmg):
757     p = subprocess.run(
758             config.get('osx_notarytool') + [
759             'submit',
760             '--apple-id',
761             config.get('apple_id'),
762             '--password',
763             config.get('apple_password'),
764             '--team-id',
765             config.get('apple_team_id'),
766             '--wait',
767             dmg
768         ], capture_output=True)
769
770     last_line = [x.strip() for x in p.stdout.decode('utf-8').splitlines() if x.strip()][-1]
771     if last_line != 'status: Accepted':
772         print("Could not understand notarytool response")
773         print(p)
774         print(f"Last line: {last_line}")
775         raise Error('Notarization failed')
776
777     subprocess.run(['xcrun', 'stapler', 'staple', dmg])
778
779
780 class OSXTarget(Target):
781     def __init__(self, directory=None):
782         super(OSXTarget, self).__init__('osx', directory)
783         self.sdk_prefix = config.get('osx_sdk_prefix')
784         self.environment_prefix = config.get('osx_environment_prefix')
785         self.apple_id = config.get('apple_id')
786         self.apple_password = config.get('apple_password')
787         self.osx_keychain_file = config.get('osx_keychain_file')
788         self.osx_keychain_password = config.get('osx_keychain_password')
789
790     def command(self, c):
791         command('%s %s' % (self.variables_string(False), c))
792
793     def unlock_keychain(self):
794         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
795
796     def package(self, project, checkout, output_dir, options, notarize):
797         self.unlock_keychain()
798         tree = globals.trees.get(project, checkout, self)
799         with TreeDirectory(tree):
800             p = self._cscript_package_and_notarize(tree, options, self.can_notarize and notarize)
801             self._copy_packages(tree, p, output_dir)
802
803     def _copy_packages(self, tree, packages, output_dir):
804         for p in packages:
805             dest = os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, p)))
806             copyfile(p, dest)
807
808     def _cscript_package_and_notarize(self, tree, options, notarize):
809         """
810         Call package() in the cscript and notarize the .dmgs that are returned, if notarize == True
811         """
812         output = []
813         for x in self._cscript_package(tree, options):
814             # Some older cscripts give us the DMG filename and the bundle ID, even though
815             # (since using notarytool instead of altool for notarization) the bundle ID
816             # is no longer necessary.  Cope with either type of cscript.
817             dmg = x[0] if isinstance(x, tuple) else x
818             if notarize:
819                 notarize_dmg(dmg)
820             output.append(dmg)
821         return output
822
823
824 class OSXSingleTarget(OSXTarget):
825     def __init__(self, arch, sdk, deployment, directory=None, can_notarize=True):
826         super(OSXSingleTarget, self).__init__(directory)
827         self.arch = arch
828         self.sdk = sdk
829         self.deployment = deployment
830         self.can_notarize = can_notarize
831         self.sub_targets = [self]
832
833         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
834         if arch == 'x86_64':
835             host_enviro = '%s/x86_64/%s' % (config.get('osx_environment_prefix'), deployment)
836         else:
837             host_enviro = '%s/x86_64/10.10' % config.get('osx_environment_prefix')
838         target_enviro = '%s/%s/%s' % (config.get('osx_environment_prefix'), arch, deployment)
839
840         self.bin = '%s/bin' % target_enviro
841
842         # Environment variables
843         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
844         self.set('CPPFLAGS', '')
845         self.set('CXXFLAGS', '"-I%s/include -I%s/include -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
846         self.set('LDFLAGS', '"-L%s/lib -L%s/lib -stdlib=libc++ %s"' % (self.directory, target_enviro, flags))
847         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
848         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
849         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
850         self.set('MACOSX_DEPLOYMENT_TARGET', self.deployment)
851         self.set('CCACHE_BASEDIR', self.directory)
852
853     @Target.ccache.setter
854     def ccache(self, v):
855         Target.ccache.fset(self, v)
856         if v:
857             self.set('CC', '"ccache gcc"')
858             self.set('CXX', '"ccache g++"')
859
860     def package(self, project, checkout, output_dir, options, notarize):
861         tree = self.build(project, checkout, options, for_package=True)
862         super().package(project, checkout, output_dir, options, notarize)
863
864
865 class OSXUniversalTarget(OSXTarget):
866     def __init__(self, directory=None):
867         super(OSXUniversalTarget, self).__init__(directory)
868         self.sdk = config.get('osx_sdk')
869         self.sub_targets = []
870         for arch, deployment in (('x86_64', config.get('osx_intel_deployment')), ('arm64', config.get('osx_arm_deployment'))):
871             target = OSXSingleTarget(arch, self.sdk, deployment, os.path.join(self.directory, arch, deployment))
872             target.ccache = self.ccache
873             self.sub_targets.append(target)
874         self.can_notarize = True
875
876     def package(self, project, checkout, output_dir, options, notarize):
877         for target in self.sub_targets:
878             tree = globals.trees.get(project, checkout, target)
879             tree.build_dependencies(options)
880             tree.build(options, for_package=True)
881
882         super().package(project, checkout, output_dir, options, notarize)
883
884     @Target.ccache.setter
885     def ccache(self, v):
886         for target in self.sub_targets:
887             target.ccache = v
888
889
890 class SourceTarget(Target):
891     """Build a source .tar.bz2 and .zst"""
892     def __init__(self):
893         super(SourceTarget, self).__init__('source')
894
895     def command(self, c):
896         log_normal('host -> %s' % c)
897         command('%s %s' % (self.variables_string(), c))
898
899     def cleanup(self):
900         rmtree(self.directory)
901
902     def package(self, project, checkout, output_dir, options, notarize):
903         tree = globals.trees.get(project, checkout, self)
904         with TreeDirectory(tree):
905             name = read_wscript_variable(os.getcwd(), 'APPNAME')
906             command('./waf dist')
907             bz2 = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
908             copyfile(bz2, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, bz2))))
909             command('tar xjf %s' % bz2)
910             command('tar --zstd -cf %s-%s.tar.zst %s-%s' % (name, tree.version, name, tree.version))
911             zstd = os.path.abspath('%s-%s.tar.zst' % (name, tree.version))
912             copyfile(zstd, os.path.join(output_dir, os.path.basename(devel_to_git(tree.commit, zstd))))
913
914
915 class LocalTarget(Target):
916     """Build on the local machine with its environment"""
917     def __init__(self, work, dependencies_only=False):
918         super(LocalTarget, self).__init__('linux', work, dependencies_only=dependencies_only)
919         # Hack around ffmpeg.git which sees that the target isn't windows/osx and then assumes
920         # distro will be there.
921         self.distro = None
922         self.detail = None
923         self.bits = 64
924         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
925         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
926         self.set('CXXFLAGS', '-I%s/include' % self.directory)
927         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
928
929     def command(self, c):
930         log_normal('host -> %s' % c)
931         command('%s %s' % (self.variables_string(), c))
932
933     def cleanup(self):
934         rmtree(self.directory)
935
936 # @param s Target string:
937 #       windows-{32,64}
938 #    or ubuntu-version-{32,64}
939 #    or debian-version-{32,64}
940 #    or centos-version-{32,64}
941 #    or fedora-version-{32,64}
942 #    or mageia-version-{32,64}
943 #    or osx
944 #    or source
945 #    or flatpak
946 #    or appimage
947 def target_factory(args):
948     s = args.target
949     target = None
950     if s.startswith('windows-'):
951         x = s.split('-')
952         if platform.system() == "Windows":
953             target = WindowsNativeTarget(args.work)
954         elif len(x) == 2:
955             target = WindowsDockerTarget(int(x[1]), args.work, args.environment_version)
956         else:
957             raise Error("Bad Windows target name `%s'")
958     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
959         p = s.split('-')
960         if len(p) != 3:
961             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
962         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
963     elif s.startswith('arch-'):
964         p = s.split('-')
965         if len(p) != 2:
966             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
967         target = LinuxTarget(p[0], None, int(p[1]), args.work)
968     elif s == 'raspbian':
969         target = LinuxTarget(s, None, None, args.work)
970     elif s == 'osx':
971         target = OSXUniversalTarget(args.work)
972     elif s == 'osx-intel':
973         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_intel_deployment'), args.work)
974     elif s == 'osx-old':
975         target = OSXSingleTarget('x86_64', config.get('osx_sdk'), config.get('osx_old_deployment'), args.work, False)
976     elif s == 'source':
977         target = SourceTarget()
978     elif s == 'flatpak':
979         target = FlatpakTarget(args.project, args.checkout, args.work)
980     elif s == 'appimage':
981         target = AppImageTarget(args.work)
982     elif s == 'local':
983         target = LocalTarget(args.work, args.dependencies_only)
984
985     if target is None:
986         raise Error("Bad target `%s'" % s)
987
988     target.debug = args.debug
989     target.ccache = args.ccache
990
991     if args.environment is not None:
992         for e in args.environment:
993             target.set(e, os.environ[e])
994
995     if args.mount is not None:
996         for m in args.mount:
997             target.mount(m)
998
999     target.setup()
1000     return target
1001
1002
1003 #
1004 # Tree
1005 #
1006
1007 class Tree:
1008     """Description of a tree, which is a checkout of a project,
1009        possibly built.  This class is never exposed to cscripts.
1010        Attributes:
1011            name -- name of git repository (without the .git)
1012            commit_ish -- git tag or revision to use
1013            target -- target object that we are using
1014            version -- version from the wscript (if one is present)
1015            commit -- git revision that is actually being used
1016            built -- true if the tree has been built yet in this run
1017            required_by -- name of the tree that requires this one
1018     """
1019
1020     def __init__(self, name, commit_ish, target, required_by, built=False):
1021         self.name = name
1022         self.commit_ish = commit_ish
1023         self.target = target
1024         self.version = None
1025         self.commit = None
1026         self.built = built
1027         self.required_by = required_by
1028
1029         cwd = os.getcwd()
1030         proj = '%s/src/%s' % (target.directory, self.name)
1031
1032         if not built:
1033             flags = ''
1034             redirect = ''
1035             if globals.quiet:
1036                 flags = '-q'
1037                 redirect = '>/dev/null'
1038             if config.has('git_reference') and globals.use_git_reference:
1039                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
1040             else:
1041                 ref = ''
1042             command('git -c protocol.file.allow=always clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
1043             os.chdir('%s/src/%s' % (target.directory, self.name))
1044
1045             if self.commit_ish is not None:
1046                 command('git checkout %s %s %s' % (flags, self.commit_ish, redirect))
1047             self.commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
1048
1049         self.cscript = {}
1050         exec(open('%s/cscript' % proj).read(), self.cscript)
1051
1052         if not built:
1053             # cscript can include submodules = False to stop submodules being fetched
1054             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
1055                 command('git submodule --quiet init')
1056                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1057                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1058                 for path, url in zip(paths, urls):
1059                     ref = ''
1060                     if config.has('git_reference') and globals.use_git_reference:
1061                         url = url.split(' ')[1]
1062                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1063                         if os.path.exists(ref_path):
1064                             ref = '--reference %s' % ref_path
1065                     path = path.split(' ')[1]
1066                     command('git -c protocol.file.allow=always submodule --quiet update %s %s' % (ref, path))
1067
1068         if os.path.exists('%s/wscript' % proj):
1069             v = read_wscript_variable(proj, "VERSION");
1070             if v is not None:
1071                 try:
1072                     self.version = Version(v)
1073                 except:
1074                     try:
1075                         tag = command_and_read('git -C %s describe --match v* --tags' % proj)[0][1:]
1076                         self.version = Version.from_git_tag(tag)
1077                     except:
1078                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1079                         # Should probably just install git on the Windows VM
1080                         pass
1081
1082         os.chdir(cwd)
1083
1084     def call(self, function, *args):
1085         with TreeDirectory(self):
1086             return self.cscript[function](self.target, *args)
1087
1088     def add_defaults(self, options):
1089         """Add the defaults from self into a dict options and returns a new dict"""
1090         new_options = copy.copy(options)
1091         if 'option_defaults' in self.cscript:
1092             from_cscript = self.cscript['option_defaults']
1093             if isinstance(from_cscript, dict):
1094                 defaults_dict = from_cscript
1095             else:
1096                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1097                 defaults_dict = from_cscript()
1098             for k, v in defaults_dict.items():
1099                 if not k in new_options:
1100                     new_options[k] = v
1101         return new_options
1102
1103     def dependencies(self, options):
1104         """
1105         yield details of the dependencies of this tree.  Each dependency is returned
1106         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1107         we want to force for 'self'.
1108         """
1109         if not 'dependencies' in self.cscript:
1110             return
1111
1112         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1113             deps = self.call('dependencies', self.add_defaults(options))
1114         else:
1115             log_normal("Deprecated cscript dependencies() method with no options parameter")
1116             deps = self.call('dependencies')
1117
1118         # Loop over our immediate dependencies
1119         for d in deps:
1120             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1121
1122             # deps only get their options from the parent's cscript
1123             dep_options = d[2] if len(d) > 2 else {}
1124             for i in dep.dependencies(dep_options):
1125                 yield i
1126             yield (dep, dep_options)
1127
1128     def checkout_dependencies(self, options={}):
1129         for i in self.dependencies(options):
1130             pass
1131
1132     def build_dependencies(self, options):
1133         """
1134         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1135         'options' will be the ones from the command line.
1136         """
1137         for i in self.dependencies(options):
1138             i[0].build(i[1])
1139
1140     def build(self, options, for_package=False):
1141         if self.built:
1142             return
1143
1144         log_verbose("Building %s %s %s with %s" % (self.name, self.commit_ish, self.version, options))
1145
1146         variables = copy.copy(self.target.variables)
1147
1148         if not globals.dry_run:
1149             num_args = len(inspect.getfullargspec(self.cscript['build']).args)
1150             if num_args == 3:
1151                 self.call('build', self.add_defaults(options), for_package)
1152             elif num_args == 2:
1153                 self.call('build', self.add_defaults(options))
1154             else:
1155                 self.call('build')
1156
1157         self.target.variables = variables
1158         self.built = True
1159
1160
1161 #
1162 # Command-line parser
1163 #
1164
1165 def main():
1166     parser = argparse.ArgumentParser()
1167     parser.add_argument('-p', '--project', help='project name')
1168     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1169     parser.add_argument('-o', '--output', help='output directory', default='.')
1170     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1171     parser.add_argument('-t', '--target', help='target', action='append')
1172     parser.add_argument('--environment-version', help='version of environment to use')
1173     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1174     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1175     parser.add_argument('-w', '--work', help='override default work directory')
1176     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1177     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1178     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1179     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1180     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1181     parser.add_argument('--ccache', help='use ccache', action='store_true')
1182     parser.add_argument('--verbose', help='be verbose', action='store_true')
1183
1184     subparsers = parser.add_subparsers(help='command to run', dest='command')
1185     parser_build = subparsers.add_parser("build", help="build project")
1186     parser_build.add_argument('--dependencies-only', help='only build dependencies', action='store_true')
1187     parser_package = subparsers.add_parser("package", help="build and package project")
1188     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1189     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1190     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1191     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1192     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1193     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1194     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1195     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1196     parser_latest.add_argument('--major', help='major version to return', type=int)
1197     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1198     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1199     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1200     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1201     parser_shell = subparsers.add_parser("shell", help="start a shell in the project's work directory")
1202     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1203     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1204     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1205     parser_notarize = subparsers.add_parser("notarize", help="notarize .dmgs in a directory")
1206     parser_notarize.add_argument('--dmgs', help='directory containing *.dmg')
1207
1208     global args
1209     args = parser.parse_args()
1210
1211     # Check for incorrect multiple parameters
1212     if args.target is not None:
1213         if len(args.target) > 1:
1214             parser.error('multiple -t options specified')
1215             sys.exit(1)
1216         else:
1217             args.target = args.target[0]
1218
1219     # Override configured stuff
1220     if args.git_prefix is not None:
1221         config.set('git_prefix', args.git_prefix)
1222
1223     if args.output.find(':') == -1:
1224         # This isn't of the form host:path so make it absolute
1225         args.output = os.path.abspath(args.output) + '/'
1226     else:
1227         if args.output[-1] != ':' and args.output[-1] != '/':
1228             args.output += '/'
1229
1230     # Now, args.output is 'host:', 'host:path/' or 'path/'
1231
1232     if args.work is not None:
1233         args.work = os.path.abspath(args.work)
1234         if not os.path.exists(args.work):
1235             os.makedirs(args.work)
1236
1237     if args.project is None and not args.command in ['shell', 'notarize']:
1238         raise Error('you must specify -p or --project')
1239
1240     globals.quiet = args.quiet
1241     globals.verbose = args.verbose
1242     globals.dry_run = args.dry_run
1243
1244     if args.command == 'build':
1245         if args.target is None:
1246             raise Error('you must specify -t or --target')
1247
1248         target = target_factory(args)
1249         try:
1250             target.build(args.project, args.checkout, get_command_line_options(args))
1251         finally:
1252             if not args.keep:
1253                 target.cleanup()
1254
1255     elif args.command == 'package':
1256         if args.target is None:
1257             raise Error('you must specify -t or --target')
1258
1259         target = None
1260         try:
1261             target = target_factory(args)
1262
1263             if target.platform == 'linux' and target.detail != "appimage":
1264                 if target.distro != 'arch':
1265                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1266                 else:
1267                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1268             else:
1269                 output_dir = args.output
1270
1271             makedirs(output_dir)
1272             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), not args.no_notarize)
1273         finally:
1274             if target is not None and not args.keep:
1275                 target.cleanup()
1276
1277     elif args.command == 'release':
1278         if args.minor is False and args.micro is False:
1279             raise Error('you must specify --minor or --micro')
1280
1281         target = SourceTarget()
1282         tree = globals.trees.get(args.project, args.checkout, target)
1283
1284         version = tree.version
1285         version.to_release()
1286         if args.minor:
1287             version.bump_minor()
1288         else:
1289             version.bump_micro()
1290
1291         with TreeDirectory(tree):
1292             command('git tag -m "v%s" v%s' % (version, version))
1293             command('git push --tags')
1294
1295         target.cleanup()
1296
1297     elif args.command == 'pot':
1298         target = SourceTarget()
1299         tree = globals.trees.get(args.project, args.checkout, target)
1300
1301         pots = tree.call('make_pot')
1302         for p in pots:
1303             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1304
1305         target.cleanup()
1306
1307     elif args.command == 'manual':
1308         target = SourceTarget()
1309         tree = globals.trees.get(args.project, args.checkout, target)
1310         tree.checkout_dependencies()
1311
1312         outs = tree.call('make_manual')
1313         for o in outs:
1314             if os.path.isfile(o):
1315                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1316             else:
1317                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1318
1319         target.cleanup()
1320
1321     elif args.command == 'doxygen':
1322         target = SourceTarget()
1323         tree = globals.trees.get(args.project, args.checkout, target)
1324
1325         dirs = tree.call('make_doxygen')
1326         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1327             dirs = [dirs]
1328
1329         for d in dirs:
1330             copytree(d, args.output)
1331
1332         target.cleanup()
1333
1334     elif args.command == 'latest':
1335         target = SourceTarget()
1336         tree = globals.trees.get(args.project, args.checkout, target)
1337
1338         with TreeDirectory(tree):
1339             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1340             latest = None
1341             line = 0
1342             while latest is None:
1343                 t = f[line]
1344                 line += 1
1345                 m = re.compile(".*\((.*)\).*").match(t)
1346                 if m:
1347                     tags = m.group(1).split(', ')
1348                     for t in tags:
1349                         s = t.split()
1350                         if len(s) > 1:
1351                             t = s[1]
1352                         if len(t) > 0 and t[0] == 'v':
1353                             v = Version(t[1:])
1354                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1355                                 latest = v
1356
1357         print(latest)
1358         target.cleanup()
1359
1360     elif args.command == 'test':
1361         if args.target is None:
1362             raise Error('you must specify -t or --target')
1363
1364         target = None
1365         try:
1366             target = target_factory(args)
1367             options = get_command_line_options(args)
1368             if args.no_implicit_build:
1369                 globals.trees.add_built(args.project, args.checkout, target)
1370             else:
1371                 target.build(args.project, args.checkout, options)
1372             target.test(args.project, args.checkout, target, args.test, options)
1373         finally:
1374             if target is not None and not args.keep:
1375                 target.cleanup()
1376
1377     elif args.command == 'shell':
1378         if args.target is None:
1379             raise Error('you must specify -t or --target')
1380
1381         target = target_factory(args)
1382         target.command('bash')
1383
1384     elif args.command == 'revision':
1385
1386         target = SourceTarget()
1387         tree = globals.trees.get(args.project, args.checkout, target)
1388         with TreeDirectory(tree):
1389             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1390         target.cleanup()
1391
1392     elif args.command == 'checkout':
1393
1394         if args.output is None:
1395             raise Error('you must specify -o or --output')
1396
1397         target = SourceTarget()
1398         tree = globals.trees.get(args.project, args.checkout, target)
1399         with TreeDirectory(tree):
1400             shutil.copytree('.', args.output)
1401         target.cleanup()
1402
1403     elif args.command == 'notarize':
1404         if args.dmgs is None:
1405             raise Error('you must specify ---dmgs')
1406
1407         for dmg in Path(args.dmgs).glob('*.dmg'):
1408             notarize_dmg(dmg)
1409
1410 try:
1411     main()
1412 except Error as e:
1413     print('cdist: %s' % str(e), file=sys.stderr)
1414     sys.exit(1)