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