# This module contains some helper functions and classes for ick # pipelines, specifically for doing release builds. They're in a # Python module, in a git repo, so they're easy to use. They started # life as a normal ick pipeline, but grew up to be big enough that # embedding the code in YAML was too tedious and hard to debug. import glob import json import os import re import sys import subprocess # Set this to False to make debug() be silent. verbose = True # Write a debug log message. def debug(*args): if verbose: print(*args) class Exec: # A class to execute external commands in a directory. def __init__(self, dirname): self.dirname = dirname def run(self, *args, **kwargs): # Run a command. if 'cwd' not in kwargs: kwargs['cwd'] = self.dirname if 'check' not in kwargs: kwargs['check'] = True if 'stdout' not in kwargs: kwargs['stdout'] = subprocess.PIPE if 'stderr' not in kwargs: kwargs['stderr'] = subprocess.STDOUT debug('RUN:', args, kwargs) x = subprocess.run(args, **kwargs) if kwargs['stdout'] == subprocess.PIPE: if x.stdout: sys.stdout.write(x.stdout.decode('UTF-8')) return x def get_stdout(self, *args, **kwargs): # Run a command, capture its stdout. Fail on non-zero exit. kwargs['check'] = True kwargs['stdout'] = subprocess.PIPE x = self.run(*args, **kwargs) return x.stdout.decode('UTF-8') def run_silently(self, *args, **kwargs): # Run a command, don't capture output. Return exit code. kwargs['stdout'] = kwargs['stderr'] = subprocess.DEVNULL kwargs['check'] = False return self.run(*args, **kwargs) def is_signed_tag(self, tag): x = self.run_silently('git', 'tag', '--verify', tag) return x.returncode == 0 def is_named_as_release_tag(self, tag, project): prefix = tag[:len(project)] suffix = tag[len(prefix):] return prefix == project and re.match(r'^-\d+\.\d+(\.\d+)*$', suffix) def is_release_tag(self, tag, project): if not self.is_signed_tag(tag): debug('tag is not signed:', tag) return False if not self.is_named_as_release_tag(tag, project): debug('tag is not named correctly for a release tag:', tag) return False debug('seems to be a release tag:', tag) return True def get_debian_source_package(self): output = self.get_stdout('dpkg-parsechangelog', '-S', 'Source') return output.strip() def get_version_from_tag(self, tag): parts = tag.split('-', 1) debug('tag parts:', parts) assert len(parts) == 2 return parts[1] def find_all_tags(self): output = self.get_stdout('git', 'tag', '-l') return output.splitlines() def find_release_tags(self, project): tags = self.find_all_tags() return [ tag for tag in tags if self.is_release_tag(tag, project) ] def create_tarball_from_tag(self, tag, filename): self.run('git', 'archive', '-o', 'temp.tar', tag) self.run('xz', '-9', 'temp.tar') self.run('mv', 'temp.tar.xz', filename) class Version: def __init__(self, full_version): self._full_version = full_version @property def full(self): return self._full_version @property def upstream(self): parts = self._full_version.split('-', 1) return parts[0] @property def debian(self): parts = self._full_version.split('-', 1) if len(parts) == 1: return None return parts[1] class DebianReleaseBuilder: def __init__(self, ex, resultsdir, debfullname, debemail): self.ex = ex self.results = resultsdir self.debfullname = debfullname self.debemail = debemail def build(self, tag, distribution): cwd = os.getcwd() self.checkout(tag) upstream_tarball = self.create_upstream_tarball(tag) debug('upstream_tarball:', upstream_tarball) self.stash(upstream_tarball) source = self.get_source_package() version = self.get_version() curdist = self.get_distribution() debug('Source package:', source) debug('Version:', version) debug('Distribution:', curdist) orig_tarball = self.create_debian_orig_tarball( upstream_tarball, source, version) if curdist != distribution: self.set_distribution(version, distribution) self.create_dsc() self.build_deb() debian_files = glob.glob('{}_{}*'.format(source, version.upstream)) debian_files = [x for x in debian_files if '+git' not in x] debug('DEBIAN:', debian_files) self.stash(*debian_files) filenames = glob.glob('{}_{}*'.format(source, version.upstream)) filenames.append(upstream_tarball) self.cleanup(filenames) debug() def checkout(self, tag): self.ex.run('git', 'reset', '--hard', check=False) self.ex.run('git', 'clean', '-fdx', check=False) self.ex.run('git', 'checkout', 'master', check=False) self.ex.run('git', 'branch', '-d', '__ickbuild', check=False) self.ex.run('git', 'checkout', '-b', '__ickbuild', tag) def create_upstream_tarball(self, tag): basename = '{}.tar.xz'.format(tag) tarball = os.path.abspath(basename) self.ex.create_tarball_from_tag(tag, tarball) return basename def create_debian_orig_tarball(self, upstream_tarball, source, version): tarball = '{}_{}.orig.tar.xz'.format(source, version.upstream) os.link(upstream_tarball, tarball) return tarball def get_source_package(self): output = self.ex.get_stdout('dpkg-parsechangelog', '-S', 'Source') return output.strip() def get_version(self): output = self.ex.get_stdout('dpkg-parsechangelog', '-S', 'Version') return Version(output.strip()) def get_distribution(self): output = self.ex.get_stdout('dpkg-parsechangelog', '-S', 'Distribution') return output.strip() def set_distribution(self, version, distribution): env = dict(os.environ) env['DEBFULLNAME'] = self.debfullname env['DEBEMAIL'] = self.debemail newver = '{}.{}'.format(version.full, distribution) debug('newver:', newver) msg = 'Build release for {} in CI.'.format(distribution) self.ex.run( 'dch', '--no-conf', '-v', newver, '-D', distribution, '--force-distribution', msg, env=env) self.ex.run('dch', '--no-conf', '-r', '', env=env) def create_dsc(self): self.ex.run('dpkg-buildpackage', '-S', '--no-sign') def build_deb(self): self.ex.run('dpkg-buildpackage', '-b', '--no-sign') def stash(self, *filenames): if not os.path.exists(self.results): os.mkdir(self.results) for filename in filenames: dst = os.path.join(self.results, filename) debug('STASH:', filename, '->', dst) os.link(filename, dst) def cleanup(self, filenames): for filename in filenames: debug('DELETE', filename) os.remove(filename) class KnownTags: filename = '.known_tags' def __init__(self): self.known = {} if os.path.exists(self.filename): with open(self.filename) as f: self.known = json.load(f) def is_empty(self): return len(self.known) == 0 def is_known(self, tag, distribution): dists = self.known.get(tag, []) return distribution in dists def remember(self, tag, distribution): dists = self.known.get(tag, []) if distribution not in dists: self.known[tag] = dists + [distribution] with open(self.filename, 'w') as f: json.dump(self.known, f, indent=4) def find_upstream_dirs(sources): for source in sources: dirname = source['location'] control = os.path.join(dirname, 'debian', 'control') if os.path.exists(control): yield dirname def build_debian_releases(params, resultsdir): sources = params['sources'] distribution = params['distribution'] debfullname = params['DEBFULLNAME'] debemail = params['DEBEMAIL'] known = KnownTags() first_build = known.is_empty() dirnames = find_upstream_dirs(sources) for dirname in dirnames: ex = Exec(dirname) project = ex.get_debian_source_package() tags = ex.find_release_tags(project) debug('release tags:', tags) builder = DebianReleaseBuilder(ex, resultsdir, debfullname, debemail) for tag in tags: if first_build: debug('First build, not building', tag, 'for', distribution) elif not known.is_known(tag, distribution): debug('Building tag', tag, 'for', distribution) builder.build(tag, distribution) else: debug('Already built, not building', tag, 'for', distribution) known.remember(tag, distribution)