# 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. assert "check" in kwargs check = kwargs.pop("check") kwargs["check"] = False if "cwd" not in kwargs: kwargs["cwd"] = self.dirname kwargs["capture_output"] = True debug("RUN:", args, kwargs) x = subprocess.run(args, **kwargs) if x.stdout: sys.stdout.write("STDOUT:\n%s" % x.stdout.decode("UTF-8")) if x.stderr: sys.stdout.write("STDERR:\n%s" % x.stderr.decode("UTF-8")) sys.stdout.write("EXIT: %s\n" % x.returncode) if check and x.returncode > 0: raise Exception("command failed") return x def get_stdout(self, *args, **kwargs): # Run a command, capture its stdout. Fail on non-zero exit. kwargs["check"] = True 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["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_project_name(self): setup_py = os.path.join(self.dirname, "setup.py") if os.path.exists(setup_py) and self.got_command("python3"): output = self.get_stdout("python3", "setup.py", "--name") elif os.path.exists(setup_py) and self.got_command("python"): output = self.get_stdout("python", "setup.py", "--name") else: output = self.get_stdout("dpkg-parsechangelog", "-S", "Source") return output.strip() def got_command(self, cmd): return self.run_silently("sh", "-c", 'command -v "$1"', "-", cmd) == 0 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, ref, filename): self.run("git", "archive", "-o", "temp.tar", ref, check=True) self.run("xz", "-9", "temp.tar", check=True) self.run("mv", "temp.tar.xz", filename, check=True) class Version: def __init__(self, full_version): self._full_version = full_version def __str__(self): return self._full_version def upstream_append(self, morever): assert "-" in self._full_version self._full_version = "{}{}-1".format(self.upstream, morever) @property def full(self): return self._full_version @property def upstream(self): parts = self._full_version.split("-") return "-".join(parts[:-1]) @property def debian(self): parts = self._full_version.split("-") if len(parts) == 1: return None return parts[-1] class DebianBuilderBase: def __init__(self, ex, debfullname, debemail): self.ex = ex self.debfullname = debfullname self.debemail = debemail def create_upstream_tarball(self, basename, ref): tarball = os.path.abspath(basename) self.ex.create_tarball_from_tag(ref, 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) debug("upstream_tarball:", tarball) return tarball def get_source_package(self): output = self.ex.get_stdout("dpkg-parsechangelog", "-S", "Source") source = output.strip() debug("Source package:", source) return source def get_version(self): output = self.ex.get_stdout("dpkg-parsechangelog", "-S", "Version") version = Version(output.strip()) debug("Version:", version) return version def get_distribution(self): output = self.ex.get_stdout("dpkg-parsechangelog", "-S", "Distribution") curdist = output.strip() debug("Distribution:", curdist) return curdist def set_distribution(self, version, distribution): env = dict(os.environ) env["DEBFULLNAME"] = self.debfullname env["DEBEMAIL"] = self.debemail msg = "Build in ick." self.ex.run( "dch", "--no-conf", "-v", str(version), "-D", distribution, "--force-distribution", msg, env=env, check=True, ) self.ex.run("dch", "--no-conf", "-r", "", env=env, check=True) def create_dsc(self): self.ex.run("dpkg-buildpackage", "-S", "--no-sign", "-sa", check=True) def build_deb(self): self.ex.run("dpkg-buildpackage", "-b", "--no-sign", check=True) class DebianReleaseBuilder(DebianBuilderBase): def __init__(self, ex, resultsdir, debfullname, debemail): super().__init__(ex, debfullname, debemail) self.results = resultsdir def build(self, tag, distribution): self.checkout(tag) basename = "{}.tar.xz".format(tag) upstream_tarball = self.create_upstream_tarball(basename, tag) self.stash(upstream_tarball) source = self.get_source_package() version = self.get_version() curdist = self.get_distribution() morever = ".{}".format(distribution) version.upstream_append(morever) 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] self.stash(*debian_files) filenames = glob.glob("{}_{}*".format(source, version.upstream)) filenames.append(upstream_tarball) self.cleanup(filenames) def checkout(self, tag): self.ex.run("git", "reset", "--hard", check=True) self.ex.run("git", "clean", "-fdx", check=True) self.ex.run("git", "checkout", "master", check=True) self.ex.run("git", "branch", "-d", "__ickbuild", check=True) self.ex.run("git", "checkout", "-b", "__ickbuild", tag, check=True) 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 DebianCIBuilder(DebianBuilderBase): def __init__(self, ex, debfullname, debemail): self.ex = ex self.debfullname = debfullname self.debemail = debemail def build(self, distribution): self.clean() source = self.get_source_package() version = self.get_version() morever = ".0ci{}.{}".format(os.environ["BUILD_NUMBER"], distribution) version.upstream_append(morever) debug("appending to upstream version:", morever) debug("full version now:", version) basename = "{}-{}.tar.xz".format(source, version.upstream) upstream_tarball = self.create_upstream_tarball(basename, "HEAD") self.create_debian_orig_tarball(upstream_tarball, source, version) self.set_distribution(version, distribution) self.create_dsc() self.build_deb() def clean(self): self.ex.run("git", "reset", "--hard", check=True) self.ex.run("git", "clean", "-fdx", check=True) 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): debug("Found debian/control in", dirname) yield dirname def build_debian_releases(params, resultsdir): debug("build_debian_releases: params=%r" % params) debug("build_debian_releases: resultsdir=%r" % resultsdir) sources = params["sources"] distribution = params["distribution_rel"] 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_project_name() 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) def ci_build_debian(params): sources = params["sources"] distribution = params["distribution_ci"] debfullname = params["DEBFULLNAME"] debemail = params["DEBEMAIL"] dirnames = find_upstream_dirs(sources) for dirname in dirnames: ex = Exec(dirname) builder = DebianCIBuilder(ex, debfullname, debemail) builder.build(distribution)