commit 3fcb5a00755f0158564bcb87053711692be037cd
parent c7145788a98a0cd751681de93755fc41151dfd21
Author: Florian Dold <florian@dold.me>
Date: Tue, 25 Nov 2025 21:01:41 +0100
fix version comparison (via Gemini 3 Pro), add testing
Diffstat:
4 files changed, 265 insertions(+), 240 deletions(-)
diff --git a/packaging/ng/taler-pkg b/packaging/ng/taler-pkg
@@ -16,7 +16,7 @@ file = Path(__file__).resolve()
parent, root = file.parent, file.parents[1]
sys.path.append(str(root))
-from util import Dpkg
+from util import vercomp
mydir = os.path.dirname(os.path.realpath(__file__))
@@ -234,18 +234,6 @@ def show_order(cfg):
print("build order:", buildorder)
-def show_published(cfg):
- distro = cfg.distro
- vendor, codename = distro.split("-", 1)
- listfmt = "${package}_${version}_${architecture}.${$type}\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 promote(cfg):
@@ -285,6 +273,59 @@ def promote(cfg):
check=True,
)
+def show_published(cfg):
+ distro = cfg.distro
+ vendor, codename = distro.split("-", 1)
+ listfmt = "${package}_${version}_${architecture}.${$type}\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 test(cfg):
+ distro = cfg.distro
+ vendor, codename = distro.split("-", 1)
+ image_tag = f"localhost/taler-packaging-{distro}:latest"
+ dockerfile = f"distros/{distro}.Dockerfile"
+ cachedir = Path(f"cache").absolute()
+ print("building base image")
+ subprocess.run(
+ [
+ "podman",
+ "build",
+ "-v",
+ f"{cachedir}/{distro}/apt-archives:/var/cache/apt/archives:z",
+ "-v",
+ f"{cachedir}/{distro}/apt-lists:/var/lib/apt/lists:z",
+ "-t",
+ image_tag,
+ "-f",
+ dockerfile,
+ ],
+ check=True,
+ )
+ print("running test")
+ cmd = [
+ "podman",
+ "run",
+ "-it",
+ "--entrypoint=/bin/bash",
+ "--security-opt",
+ "label=disable",
+ "--mount",
+ f"type=bind,source={mydir}/testing/test-{distro},target=/runtest,readonly",
+ image_tag,
+ "/runtest",
+ ]
+ subprocess.run(
+ cmd,
+ check=True,
+ )
def publish(cfg):
distro = cfg.distro
@@ -329,7 +370,7 @@ def publish(cfg):
pkg2, ver2, *rest2 = srvdeb.removesuffix(".deb").split("_")
if pkg1 != pkg2:
continue
- if Dpkg.compare_versions(ver1, ver2) <= 0:
+ if vercomp.compare_versions(ver1, ver2) <= 0:
fresh = False
server_deb = srvdeb
break
@@ -503,6 +544,17 @@ def main():
"--dry", help="Dry run", action="store_true", default=False
)
+ parser_test = subparsers.add_parser("test", help="Test packages for distro.")
+ parser_test.set_defaults(func=test)
+ parser_test.add_argument("distro")
+ parser_test.add_argument(
+ "--arch",
+ help="Architecture(s) to test packages for",
+ action="store",
+ dest="arch",
+ default=None,
+ )
+
# subcommand show-latest
parser_show_latest = subparsers.add_parser(
diff --git a/packaging/ng/testing/test-debian-trixie b/packaging/ng/testing/test-debian-trixie
@@ -0,0 +1,20 @@
+#!/bin/bash
+
+set -eu
+
+wget -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg
+
+cat >/etc/apt/sources.list.d/taler.sources <<EOF
+Architectures: amd64
+Components: main
+X-Repolib-Name: Taler
+Signed-By: /etc/apt/keyrings/taler-systems.gpg
+Suites: trixie-testing
+Types: deb
+URIs: https://deb.taler.net/apt/debian
+EOF
+
+apt-get update
+apt-get upgrade
+
+apt-get install taler-exchange taler-merchant donau taler-harness taler-wallet-cli
diff --git a/packaging/ng/util/__init__.py b/packaging/ng/util/__init__.py
@@ -1,226 +0,0 @@
-# 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_revision_strings(rev1, rev2):
- """Compare two debian revision strings as described at
- https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version
- """
- if rev1 == rev2:
- return 0
- # listify pads results so that we will always be comparing ints to ints
- # and strings to strings (at least until we fall off the end of a list)
- list1 = Dpkg.listify(rev1)
- list2 = Dpkg.listify(rev2)
- if list1 == list2:
- return 0
- try:
- for i, item in enumerate(list1):
- # just in case
- if not isinstance(item, list2[i].__class__):
- raise DpkgVersionError(
- 'Cannot compare %s to %s, something has gone horribly '
- 'awry.' % (item, list2[i]))
- # if the items are equal, next
- if item == list2[i]:
- continue
- # numeric comparison
- if isinstance(item, int):
- if item > list2[i]:
- return 1
- if item < list2[i]:
- return -1
- else:
- # string comparison
- return Dpkg.dstringcmp(item, list2[i])
- except IndexError:
- # rev1 is longer than rev2 but otherwise equal, hence greater
- return 1
- # rev1 is shorter than rev2 but otherwise equal, hence lesser
- return -1
-
- @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
-
diff --git a/packaging/ng/util/vercomp.py b/packaging/ng/util/vercomp.py
@@ -0,0 +1,179 @@
+import re
+
+def order(char: str) -> int:
+ """
+ Determines the sort weight of a character in the non-digit part of a version.
+
+ According to deb-version(5):
+ 1. Tilde (~) sorts before everything.
+ 2. The end of a part sorts before anything else (except tilde).
+ 3. Letters sort earlier than non-letters (symbols).
+ """
+ if char == '~':
+ return -1
+ if char == '' or char is None:
+ return 0
+ if char.isalpha():
+ return ord(char)
+ # Map non-alphanumeric symbols (other than ~) higher than letters
+ # Standard ASCII letters are < 128, so adding 256 pushes symbols above them.
+ return ord(char) + 256
+
+def compare_string_portions(val_a: str, val_b: str) -> int:
+ """
+ Compares two string portions (upstream or debian revision) using the
+ algorithm described in deb-version(5).
+ """
+ # Use lists as mutable cursors
+ list_a = list(val_a)
+ list_b = list(val_b)
+
+ while list_a or list_b:
+ # --- 1. Compare non-digit initial parts ---
+
+ # Extract non-digit prefixes
+ non_digit_a = []
+ while list_a and not list_a[0].isdigit():
+ non_digit_a.append(list_a.pop(0))
+
+ non_digit_b = []
+ while list_b and not list_b[0].isdigit():
+ non_digit_b.append(list_b.pop(0))
+
+ # Compare them character by character
+ # We loop until we exhaust the longest of the two non-digit strings
+ # (effectively padding the shorter one with empty strings/None)
+ len_cmp = max(len(non_digit_a), len(non_digit_b))
+
+ for i in range(len_cmp):
+ # Get chars or None if exhausted
+ ca = non_digit_a[i] if i < len(non_digit_a) else None
+ cb = non_digit_b[i] if i < len(non_digit_b) else None
+
+ wa = order(ca)
+ wb = order(cb)
+
+ if wa < wb: return -1
+ if wa > wb: return 1
+
+ # --- 2. Compare numerical parts ---
+
+ # Extract digit prefixes
+ digit_a_str = []
+ while list_a and list_a[0].isdigit():
+ digit_a_str.append(list_a.pop(0))
+
+ digit_b_str = []
+ while list_b and list_b[0].isdigit():
+ digit_b_str.append(list_b.pop(0))
+
+ # Convert to int (empty string defaults to 0)
+ num_a = int("".join(digit_a_str)) if digit_a_str else 0
+ num_b = int("".join(digit_b_str)) if digit_b_str else 0
+
+ if num_a < num_b: return -1
+ if num_a > num_b: return 1
+
+ return 0
+
+def parse_version(version: str):
+ """
+ Parses a version string into (epoch, upstream, debian).
+ Defaults: epoch=0, debian='0' if missing.
+ """
+ epoch = 0
+ upstream = version
+ debian = "0"
+
+ # 1. Parse Epoch (last colon wins? No, first colon)
+ # deb-version: "The epoch is a single (generally small) unsigned integer
+ # and may be omitted. If it is omitted then the upstream_version may
+ # not contain any colons."
+ if ':' in version:
+ epoch_str, remainder = version.split(':', 1)
+ if epoch_str.isdigit():
+ epoch = int(epoch_str)
+ upstream = remainder
+
+ # 2. Parse Debian Revision (last hyphen splits upstream and debian)
+ # deb-version: "If there is no debian_revision then hyphens are not
+ # allowed [in upstream]; if there is no debian_revision then the
+ # upstream_version is the whole version string."
+ r_hyphen_index = upstream.rfind('-')
+ if r_hyphen_index != -1:
+ # Found a hyphen, split
+ debian = upstream[r_hyphen_index + 1:]
+ upstream = upstream[:r_hyphen_index]
+
+ return epoch, upstream, debian
+
+def compare_versions(ver_a: str, ver_b: str) -> int:
+ """
+ Compare two Debian version strings.
+
+ Returns:
+ -1 if ver_a < ver_b
+ 0 if ver_a == ver_b
+ 1 if ver_a > ver_b
+ """
+ # 1. Parse
+ epoch_a, up_a, deb_a = parse_version(ver_a)
+ epoch_b, up_b, deb_b = parse_version(ver_b)
+
+ # 2. Compare Epochs (Integers)
+ if epoch_a < epoch_b: return -1
+ if epoch_a > epoch_b: return 1
+
+ # 3. Compare Upstream Versions (String Logic)
+ res = compare_string_portions(up_a, up_b)
+ if res != 0: return res
+
+ # 4. Compare Debian Revisions (String Logic)
+ return compare_string_portions(deb_a, deb_b)
+
+# --- Tests and Examples ---
+if __name__ == "__main__":
+ test_cases = [
+ # (Version A, Version B, Operator Expected)
+ # 1.0 < 1.1
+ ("1.0", "1.1", "<"),
+ # 1.0-1 < 1.0-2
+ ("1.0-1", "1.0-2", "<"),
+ # Tilde logic: 1.0~rc1 < 1.0
+ ("1.0~rc1", "1.0", "<"),
+ # Tilde sorts before empty string (end of chunk)
+ # "1.0" parses as 1, ., 0, (end)
+ # "1.0~" parses as 1, ., 0, ~
+ # ~ < (end), so 1.0~ < 1.0
+ ("1.0~", "1.0", "<"),
+ # Letter vs Symbol logic: 1.0a < 1.0+
+ # 'a' (letter) sorts earlier than '+' (symbol)
+ ("1.0a", "1.0+", "<"),
+ # Epoch logic
+ ("1:1.0", "1.0", ">"),
+ # Digit comparison: 1.0 == 1.00
+ ("1.0", "1.00", "="),
+ # Parsing split logic
+ ("1.2.3-1", "1.2.3-1", "="),
+ ("1.2.3-alpha", "1.2.3-beta", "<"),
+ # Tilde
+ ("1.1.2~trixie", "1.1.2", "<"),
+ ]
+
+ print(f"{'Version A':<15} {'Op':<5} {'Version B':<15} {'Result':<10}")
+ print("-" * 50)
+
+ for va, vb, expected_op in test_cases:
+ result = compare_versions(va, vb)
+
+ op_map = { -1: "<", 0: "=", 1: ">" }
+ actual_op = op_map[result]
+
+ # Check correctness
+ passed = False
+ if expected_op == "<" and result == -1: passed = True
+ elif expected_op == ">" and result == 1: passed = True
+ elif expected_op == "=" and result == 0: passed = True
+
+ status = "PASS" if passed else "FAIL"
+ print(f"{va:<15} {actual_op:<5} {vb:<15} {status}")