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