taler-deployment

Deployment scripts and configuration files
Log | Files | Refs | README

commit 97d59d1d3caf32f72607d4fa1d0ab1ef0abf7d47
parent 98f17ec875e8596a3641933d8243ee69086e147a
Author: Florian Dold <florian@dold.me>
Date:   Tue, 10 Jun 2025 20:33:55 +0200

packaging: python-only version comparison

Diffstat:
Mpackaging/ng/.gitignore | 1+
Mpackaging/ng/taler-pkg | 65++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Apackaging/ng/util/__init__.py | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 239 insertions(+), 15 deletions(-)

diff --git a/packaging/ng/.gitignore b/packaging/ng/.gitignore @@ -1,2 +1,3 @@ packages/ cache/ +*.pyc diff --git a/packaging/ng/taler-pkg b/packaging/ng/taler-pkg @@ -8,8 +8,16 @@ import argparse import subprocess from dataclasses import dataclass import os +import sys from pathlib import Path +# Make local util package available +file = Path(__file__).resolve() +parent, root = file.parent, file.parents[1] +sys.path.append(str(root)) + +from util import Dpkg + mydir = os.path.dirname(os.path.realpath(__file__)) host = "taler.net" @@ -26,8 +34,8 @@ components = [ "taler-harness", "taler-merchant", "robocop", - #"taler-mdb", - #"taler-merchant-demos", + # "taler-mdb", + # "taler-merchant-demos", "taler-wallet-cli", "taler-directory", "taler-mailbox", @@ -38,7 +46,7 @@ deps = { "anastasis": ["gnunet", "taler-merchant"], "anastasis-gtk": ["anastasis", "gnunet-gtk"], "taler-merchant": ["gnunet", "taler-exchange"], - #"taler-mdb": ["gnunet", "taler-exchange", "taler-merchant"], + # "taler-mdb": ["gnunet", "taler-exchange", "taler-merchant"], "sync": ["taler-merchant", "taler-exchange", "gnunet"], "gnunet-gtk": ["gnunet"], } @@ -51,6 +59,7 @@ for n1, d in deps.items(): if n1 not in rd: rd.append(n1) + def buildsort(roots): """Toposort transitive closure of roots based on deps""" out = [] @@ -182,11 +191,9 @@ def show_published(cfg): listfmt = "${package}_${version}_${architecture}.deb\n" subprocess.run(["ssh", f"taler-packaging@{host}", f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ --list-format '{listfmt}' list {codename}"], check=True) - def publish(cfg): distro = cfg.distro vendor, codename = distro.split("-") - #debs = list(Path(f"./packages/{distro}/").glob("*.deb")) debs = [] for component in components: current = None @@ -205,13 +212,23 @@ def publish(cfg): if cfg.dry: return debs = [Path(f"./packages/{distro}/") / x for x in debs] - subprocess.run(["ssh", f"taler-packaging@{host}", f"rm -f '{distro}/*.deb'"], check=True) - subprocess.run(["scp", "--", *debs, f"taler-packaging@{host}:{distro}/"], check=True) + subprocess.run( + ["ssh", f"taler-packaging@{host}", f"rm -f '{distro}/*.deb'"], check=True + ) + subprocess.run( + ["scp", "--", *debs, f"taler-packaging@{host}:{distro}/"], check=True + ) # FIXME: This fails when packages of the same version are already present. # That's a problem since builds are not bit-reproducible. - subprocess.run(["ssh", f"taler-packaging@{host}", f"./include-{distro}.sh"], check=True) - subprocess.run(["ssh", f"taler-packaging@{host}", f"./export-{distro}.sh"], check=True) - subprocess.run(["ssh", f"taler-packaging@{host}", f"./show-{distro}.sh"], check=True) + subprocess.run( + ["ssh", f"taler-packaging@{host}", f"./include-{distro}.sh"], check=True + ) + subprocess.run( + ["ssh", f"taler-packaging@{host}", f"./export-{distro}.sh"], check=True + ) + subprocess.run( + ["ssh", f"taler-packaging@{host}", f"./show-{distro}.sh"], check=True + ) def main(): @@ -227,9 +244,23 @@ def main(): parser_build.set_defaults(func=build) parser_build.add_argument("distro") # Keep for backwards compat - parser_build.add_argument("--no-transitive", help="Do not build transitive deps of changed components (default)", action="store_true", dest="transitive", default=None) - parser_build.add_argument("--transitive", help="Build transitive deps of changed components", action="store_false", dest="transitive", default=None) - parser_build.add_argument("--dry", help="Dry run", action="store_true", default=False) + parser_build.add_argument( + "--no-transitive", + help="Do not build transitive deps of changed components (default)", + action="store_true", + dest="transitive", + default=None, + ) + parser_build.add_argument( + "--transitive", + help="Build transitive deps of changed components", + action="store_false", + dest="transitive", + default=None, + ) + parser_build.add_argument( + "--dry", help="Dry run", action="store_true", default=False + ) # subcommand show-latest @@ -245,14 +276,18 @@ def main(): parser_show_order.add_argument("roots", nargs="+") # subcommand show-published - parser_show_published = subparsers.add_parser("show-published", help="Show published packages on deb.taler.net") + parser_show_published = subparsers.add_parser( + "show-published", help="Show published packages on deb.taler.net" + ) parser_show_published.add_argument("distro") parser_show_published.set_defaults(func=show_published) # subcommand publish parser_publish = subparsers.add_parser("publish", help="Publish to deb.taler.net") - parser_publish.add_argument("--dry", help="Dry run", action="store_true", default=False) + parser_publish.add_argument( + "--dry", help="Dry run", action="store_true", default=False + ) parser_publish.add_argument("distro") parser_publish.set_defaults(func=publish) diff --git a/packaging/ng/util/__init__.py b/packaging/ng/util/__init__.py @@ -0,0 +1,188 @@ +# Class "Dpkg" adapted in modified form from python-dpkg: +# https://github.com/TheClimateCorporation/python-dpkg/blob/master/pydpkg/__init__.py +# +# License: +# +# Copyright [2017] The Climate Corporation (https://climate.com) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +class Dpkg: + @staticmethod + def get_upstream(version_str): + """Given a version string that could potentially contain both an upstream + revision and a debian revision, return a tuple of both. If there is no + debian revision, return 0 as the second tuple element.""" + try: + d_index = version_str.rindex("-") + except ValueError: + # no hyphens means no debian version, also valid. + return version_str, "0" + return version_str[0:d_index], version_str[d_index + 1 :] + + @staticmethod + def get_epoch(version_str): + """Parse the epoch out of a package version string. + Return (epoch, version); epoch is zero if not found.""" + try: + # there could be more than one colon, + # but we only care about the first + e_index = version_str.index(":") + except ValueError: + # no colons means no epoch; that's valid, man + return 0, version_str + try: + epoch = int(version_str[0:e_index]) + except ValueError: + raise Exception( + "Corrupt dpkg version %s: epochs can only be ints, and " + "epochless versions cannot use the colon character." % version_str + ) + return epoch, version_str[e_index + 1 :] + + @staticmethod + def split_full_version(version_str): + """Split a full version string into epoch, upstream version and + debian revision.""" + epoch, full_ver = Dpkg.get_epoch(version_str) + upstream_rev, debian_rev = Dpkg.get_upstream(full_ver) + return epoch, upstream_rev, debian_rev + + @staticmethod + def get_alphas(revision_str): + """Return a tuple of the first non-digit characters of a revision (which + may be empty) and the remaining characters.""" + # get the index of the first digit + for i, char in enumerate(revision_str): + if char.isdigit(): + if i == 0: + return "", revision_str + return revision_str[0:i], revision_str[i:] + # string is entirely alphas + return revision_str, "" + + @staticmethod + def get_digits(revision_str): + """Return a tuple of the first integer characters of a revision (which + may be empty) and the remains.""" + # If the string is empty, return (0,'') + if not revision_str: + return 0, "" + # get the index of the first non-digit + for i, char in enumerate(revision_str): + if not char.isdigit(): + if i == 0: + return 0, revision_str + return int(revision_str[0:i]), revision_str[i:] + # string is entirely digits + return int(revision_str), "" + + @staticmethod + def listify(revision_str): + """Split a revision string into a list of alternating between strings and + numbers, padded on either end to always be "str, int, str, int..." and + always be of even length. This allows us to trivially implement the + comparison algorithm described at + http://debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version + """ + result = [] + while revision_str: + rev_1, remains = Dpkg.get_alphas(revision_str) + rev_2, remains = Dpkg.get_digits(remains) + result.extend([rev_1, rev_2]) + revision_str = remains + return result + + @staticmethod + def dstringcmp(a, b): + """debian package version string section lexical sort algorithm + + "The lexical comparison is a comparison of ASCII values modified so + that all the letters sort earlier than all the non-letters and so that + a tilde sorts before anything, even the end of a part." + """ + + if a == b: + return 0 + try: + for i, char in enumerate(a): + if char == b[i]: + continue + # "a tilde sorts before anything, even the end of a part" + # (emptyness) + if char == "~": + return -1 + if b[i] == "~": + return 1 + # "all the letters sort earlier than all the non-letters" + if char.isalpha() and not b[i].isalpha(): + return -1 + if not char.isalpha() and b[i].isalpha(): + return 1 + # otherwise lexical sort + if ord(char) > ord(b[i]): + return 1 + if ord(char) < ord(b[i]): + return -1 + except IndexError: + # a is longer than b but otherwise equal, hence greater + # ...except for goddamn tildes + if char == "~": + return -1 + return 1 + # if we get here, a is shorter than b but otherwise equal, hence lesser + # ...except for goddamn tildes + if b[len(a)] == "~": + return 1 + return -1 + + @staticmethod + def split_full_version(version_str): + """Split a full version string into epoch, upstream version and + debian revision.""" + epoch, full_ver = Dpkg.get_epoch(version_str) + upstream_rev, debian_rev = Dpkg.get_upstream(full_ver) + return epoch, upstream_rev, debian_rev + + @staticmethod + def compare_versions(ver1, ver2): + """Function to compare two Debian package version strings, + suitable for passing to list.sort() and friends.""" + if ver1 == ver2: + return 0 + + # note the string conversion: the debian policy here explicitly + # specifies ASCII string comparisons, so if you are mad enough to + # actually cram unicode characters into your package name, you are on + # your own. + epoch1, upstream1, debian1 = Dpkg.split_full_version(str(ver1)) + epoch2, upstream2, debian2 = Dpkg.split_full_version(str(ver2)) + + # if epochs differ, immediately return the newer one + if epoch1 < epoch2: + return -1 + if epoch1 > epoch2: + return 1 + + # then, compare the upstream versions + upstr_res = Dpkg.compare_revision_strings(upstream1, upstream2) + if upstr_res != 0: + return upstr_res + + debian_res = Dpkg.compare_revision_strings(debian1, debian2) + if debian_res != 0: + return debian_res + + # at this point, the versions are equal, but due to an interpolated + # zero in either the epoch or the debian version + return 0 +