commit 11086493c0f205cad03a085acef854fd92cd426c
parent 78de19c38700070ab3fa965f573f180997c749ff
Author: Florian Dold <dold@taler.net>
Date: Tue, 8 Sep 2026 22:11:17 +0200
CI packaging: derive versions from the highest SemVer tag
Select stable and dev.K tags across the full repository, ignoring build
metadata. Use next-patch -0.N and dev.K.N snapshot versions with
matching Debian ordering, and determine exact releases by commit
identity.
Fetch tags for the scheduled commit without limiting history. Stop
packaging when version generation fails or returns an empty version.
Diffstat:
4 files changed, 337 insertions(+), 17 deletions(-)
diff --git a/contrib/ci/debian-version.py b/contrib/ci/debian-version.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+# This file is in the public domain.
+"""Derive CI versions from the highest supported SemVer tag in the repository.
+
+Keep this helper and test_version.py identical across Buildbot Debian publishers.
+Only vX.Y.Z and vX.Y.Z-dev.K (optionally with build metadata) are release tags.
+Snapshots use X.Y.(Z+1)-0.N or X.Y.Z-dev.K.N in SemVer, and replace the
+prerelease separator with '~' and 'dev.' with 'dev' for Debian. Metadata and
+commit hashes do not affect either version. N counts tag..HEAD commits; only
+commit identity, not a zero count, makes a build an exact tagged release.
+"""
+
+import re
+import subprocess
+import sys
+
+NUMBER = r"(0|[1-9][0-9]*)"
+TAG = re.compile(
+ rf"v{NUMBER}\.{NUMBER}\.{NUMBER}(?:-dev\.{NUMBER})?"
+ r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
+)
+
+
+def parse_tag(name):
+ """Return a sortable precedence tuple, or None for an unsupported tag."""
+ match = TAG.fullmatch(name)
+ if match is None:
+ return None
+ major, minor, patch, dev, _ = match.groups()
+ return (int(major), int(minor), int(patch), dev is None,
+ 0 if dev is None else int(dev))
+
+
+def select_tag(names):
+ candidates = []
+ for name in names:
+ rank = parse_tag(name)
+ if rank is None:
+ print(f"Ignoring unsupported version tag: {name}", file=sys.stderr)
+ else:
+ candidates.append((rank, name))
+ if not candidates:
+ raise ValueError("no supported version tags (expected vX.Y.Z or vX.Y.Z-dev.K)")
+ highest = max(rank for rank, _ in candidates)
+ # Metadata is irrelevant to precedence. Prefer the plain tag, then the
+ # lexically first tag name, independent of Git's configured tag sorting.
+ return min((name for rank, name in candidates if rank == highest),
+ key=lambda name: ("+" in name, name))
+
+
+def versions(tag, exact, count):
+ major, minor, patch, stable, dev = parse_tag(tag)
+ if stable:
+ base = f"{major}.{minor}.{patch if exact else patch + 1}"
+ suffix = "" if exact else f"0.{count}"
+ else:
+ base = f"{major}.{minor}.{patch}"
+ suffix = f"dev.{dev}" + ("" if exact else f".{count}")
+ semver = base + (f"-{suffix}" if suffix else "")
+ debian = base + ("~" + suffix.replace("dev.", "dev", 1) if suffix else "")
+ return semver, debian
+
+
+def git(*args):
+ return subprocess.check_output(["git", *args], text=True).strip()
+
+
+def main():
+ try:
+ if git("rev-parse", "--is-shallow-repository") != "false":
+ raise ValueError("a full Git checkout is required; disable shallow cloning")
+ head = git("rev-parse", "HEAD")
+ # Fetch the scheduled commit, including on detached HEADs, and all tags.
+ # Do not impose a depth limit or move HEAD to the selected tag.
+ subprocess.run(["git", "fetch", "--no-recurse-submodules", "--tags",
+ "origin", head], check=True, stdout=sys.stderr)
+ tag = select_tag(git("tag", "--list").splitlines())
+ tagged_commit = git("rev-parse", f"{tag}^{{commit}}")
+ count = int(git("rev-list", "--count", f"{tag}..{head}"))
+ semver, debian = versions(tag, head == tagged_commit, count)
+ print(f"Version tag: {tag}; HEAD: {head}; commits: {count}; "
+ f"SemVer: {semver}; Debian: {debian}", file=sys.stderr)
+ print(debian)
+ return 0
+ except (OSError, subprocess.CalledProcessError, ValueError) as error:
+ print(f"Cannot determine CI version: {error}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/contrib/ci/jobs/3-deb-amd64/job.sh b/contrib/ci/jobs/3-deb-amd64/job.sh
@@ -6,7 +6,9 @@ apt-get update -yq
apt-get upgrade -yq
# Build package
-export VERSION="$(./contrib/ci/version.sh)"
+VERSION="$(./contrib/ci/version.sh)"
+export VERSION
+: "${VERSION:?version generation returned an empty version}"
echo "Building package version ${VERSION}"
EMAIL=none gbp dch --dch-opt=-b --ignore-branch --debian-tag="%(version)s" --git-author --new-version="${VERSION}"
make deb
diff --git a/contrib/ci/test_version.py b/contrib/ci/test_version.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+# This file is in the public domain.
+"""Run with python3 contrib/ci/test_version.py (Git; optional dpkg comparator)."""
+
+import contextlib
+import importlib.util
+import io
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+import uuid
+
+CI = Path(__file__).resolve().parent
+spec = importlib.util.spec_from_file_location("ci_version", CI / "debian-version.py")
+version = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(version)
+
+
+def run(*args, cwd, check=True, env=None):
+ return subprocess.run(args, cwd=cwd, check=check, text=True,
+ capture_output=True, env=env)
+
+
+def script(path, text):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("#!/bin/sh\n" + text + "\n")
+ path.chmod(0o755)
+
+
+def install_scripts(repo):
+ for source in [CI / "debian-version.py", *CI.rglob("version.sh")]:
+ dest = repo / "contrib/ci" / source.relative_to(CI)
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, dest)
+
+
+class PolicyTests(unittest.TestCase):
+ def test_tag_order_and_metadata_ties(self):
+ cases = [
+ (["v1.9.9", "v1.10.0-dev.0"], "v1.10.0-dev.0"),
+ (["v1.2.3-dev.9", "v1.2.3-dev.10"], "v1.2.3-dev.10"),
+ (["v1.2.3-dev.999", "v1.2.3"], "v1.2.3"),
+ (["v1.2.3+z", "v1.2.3+a", "v1.2.3"], "v1.2.3"),
+ (["v1.2.3+z", "v1.2.3+a"], "v1.2.3+a"),
+ (["v1.2.3-dev.1+x", "v1.2.3-dev.1"], "v1.2.3-dev.1"),
+ ]
+ for tags, expected in cases:
+ for names in (tags, list(reversed(tags))):
+ self.assertEqual(version.select_tag(names), expected)
+
+ def test_unsupported_tags_are_reported(self):
+ tags = ["v01.2.3", "v1.02.3", "v1.2.03", "v1.2.3-dev.01",
+ "1.2.3", "v1.2.3-rc.1", "v1.2.3-dev.1.2", "v1.2.3-0.1",
+ "v1.2.3+", "v1.2.3+a..b", "v1.2.3+bad_tag", "v0.9.4a"]
+ with contextlib.redirect_stderr(io.StringIO()) as diagnostics:
+ self.assertEqual(version.select_tag(tags + ["v0.0.0"]), "v0.0.0")
+ with self.assertRaises(ValueError):
+ version.select_tag(tags)
+ for tag in tags:
+ self.assertIn(tag, diagnostics.getvalue())
+
+ def test_mappings(self):
+ cases = [
+ ("v1.2.3", True, 0, "1.2.3", "1.2.3"),
+ ("v1.2.3", False, 0, "1.2.4-0.0", "1.2.4~0.0"),
+ ("v1.2.3", False, 12, "1.2.4-0.12", "1.2.4~0.12"),
+ ("v1.2.4-dev.9", True, 0, "1.2.4-dev.9", "1.2.4~dev9"),
+ ("v1.2.4-dev.9", False, 0, "1.2.4-dev.9.0", "1.2.4~dev9.0"),
+ ("v1.2.4-dev.9", False, 12, "1.2.4-dev.9.12", "1.2.4~dev9.12"),
+ ]
+ for tag, exact, count, semver, debian in cases:
+ for metadata in ("", "+build.001", "+other"):
+ self.assertEqual(version.versions(tag + metadata, exact, count),
+ (semver, debian))
+
+ @unittest.skipUnless(shutil.which("dpkg"), "dpkg is needed for Debian ordering")
+ def test_debian_and_semver_order_agree(self):
+ # This sequence is increasing under SemVer's numeric identifier and
+ # longer-prerelease rules, including patch rollover and dev 9 -> 10.
+ cases = [("v1.2.3", True, 0)]
+ cases += [("v1.2.3", False, n) for n in (0, 2, 10)]
+ for dev in (0, 1, 9, 10):
+ cases += [(f"v1.2.4-dev.{dev}", True, 0)]
+ cases += [(f"v1.2.4-dev.{dev}", False, n) for n in (0, 2, 10)]
+ cases += [("v1.2.4", True, 0), ("v1.2.4", False, 0),
+ ("v1.2.99", False, 10), ("v1.3.0-dev.0", True, 0)]
+ pairs = [version.versions(*case) for case in cases]
+ for index, (_, left) in enumerate(pairs):
+ for _, right in pairs[index + 1:]:
+ result = run("dpkg", "--compare-versions", left, "lt", right,
+ cwd=CI, check=False)
+ self.assertEqual(result.returncode, 0, (left, right))
+ run("dpkg", "--compare-versions", left, "eq", left, cwd=CI)
+
+
+class GitTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.base = Path(self.temp.name)
+ self.origin = self.base / "origin"
+ self.repo = self.base / "checkout"
+ run("git", "init", "-q", str(self.origin), cwd=self.base)
+ self.git("config", "user.name", "Version test")
+ self.git("config", "user.email", "version@example.invalid")
+ self.commit()
+
+ def git(self, *args):
+ return run("git", *args, cwd=self.origin).stdout.strip()
+
+ def commit(self):
+ self.git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", str(uuid.uuid4()))
+ return self.git("rev-parse", "HEAD")
+
+ def clone(self, shallow=False):
+ args = ["git", "clone", "-q"] + (["--depth=1"] if shallow else [])
+ run(*args, self.origin.as_uri(), str(self.repo), cwd=self.base)
+ install_scripts(self.repo)
+ run("git", "checkout", "--detach", cwd=self.repo)
+
+ def check_version(self, expected, selected=None):
+ nested = self.repo / "packages/test"
+ nested.mkdir(parents=True, exist_ok=True)
+ for entry in (self.repo / "contrib/ci").rglob("version.sh"):
+ with self.subTest(entry=entry.relative_to(self.repo)):
+ result = run(str(entry), cwd=nested)
+ self.assertEqual(result.stdout, expected + "\n")
+ self.assertIn("HEAD:", result.stderr)
+ if selected:
+ self.assertIn(f"Version tag: {selected};", result.stderr)
+
+ def test_detached_annotated_and_lightweight_tags(self):
+ self.git("-c", "tag.gpgsign=false", "tag", "-am", "release", "v1.2.3")
+ self.git("tag", "v1.2.3+metadata")
+ self.clone()
+ self.check_version("1.2.3", "v1.2.3")
+
+ def test_fetches_new_tags_without_moving_head(self):
+ self.clone()
+ head = self.git("rev-parse", "HEAD")
+ self.git("tag", "v1.2.3-dev.10")
+ self.check_version("1.2.3~dev10")
+ self.assertEqual(run("git", "rev-parse", "HEAD", cwd=self.repo).stdout.strip(), head)
+
+ def test_snapshots_after_stable_and_dev_tags(self):
+ self.git("tag", "v1.2.3")
+ self.commit()
+ self.clone()
+ self.check_version("1.2.4~0.1")
+ self.git("tag", "v1.2.4-dev.9", "HEAD~1")
+ self.check_version("1.2.4~dev9.1")
+
+ def test_highest_tag_on_another_branch(self):
+ base = self.git("rev-parse", "HEAD")
+ self.git("checkout", "-qb", "release")
+ self.commit()
+ self.git("tag", "v2.0.0-dev.10")
+ self.git("checkout", "--detach", base)
+ self.commit()
+ self.git("tag", "v1.0.0")
+ self.clone()
+ self.check_version("2.0.0~dev10.1", "v2.0.0-dev.10")
+
+ def test_zero_distance_is_not_exact(self):
+ base = self.git("rev-parse", "HEAD")
+ self.commit()
+ self.git("tag", "v2.0.0")
+ self.git("checkout", "--detach", base)
+ self.clone()
+ self.check_version("2.0.1~0.0")
+
+ def test_errors_have_no_version_output(self):
+ self.clone()
+ entry = str(self.repo / "contrib/ci/version.sh")
+ result = run(entry, cwd=self.repo, check=False)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(result.stdout, "")
+ self.assertIn("no supported version tags", result.stderr)
+ self.git("tag", "v1.0.0")
+ run("git", "remote", "set-url", "origin", str(self.base / "missing"), cwd=self.repo)
+ result = run(entry, cwd=self.repo, check=False)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(result.stdout, "")
+ self.assertIn("Cannot determine CI version", result.stderr)
+
+ def test_shallow_checkout_is_rejected(self):
+ self.git("tag", "v1.0.0")
+ self.commit()
+ self.clone(shallow=True)
+ result = run(str(self.repo / "contrib/ci/version.sh"), cwd=self.repo, check=False)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(result.stdout, "")
+ self.assertIn("full Git checkout is required", result.stderr)
+
+
+class PackagingTests(unittest.TestCase):
+ def test_callers_stop_on_failed_or_empty_version_and_pass_success(self):
+ callers = [p for p in CI.rglob("*.sh")
+ if p.is_file() and "VERSION=" in p.read_text()]
+ self.assertTrue(callers)
+ for caller in callers:
+ for output, status in (("", 42), ("", 0), ("1.2.3~dev9.12", 0)):
+ with self.subTest(caller=caller.relative_to(CI), output=output, status=status):
+ with tempfile.TemporaryDirectory() as temp:
+ repo = Path(temp)
+ install_scripts(repo)
+ dest = repo / "contrib/ci" / caller.relative_to(CI)
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(caller, dest)
+ # Stub all version entry points to isolate shell error handling.
+ for entry in (repo / "contrib/ci").rglob("version.sh"):
+ script(entry, f"printf '%s' '{output}'\nexit {status}")
+ script(repo / "bootstrap", "exit 0")
+ for pkg in ("taler-wallet-cli", "taler-harness", "test-webui"):
+ (repo / "packages" / pkg / "debian").mkdir(parents=True)
+ bindir = repo / "bin"
+ for name in ("apt-get", "mk-build-deps"):
+ script(bindir / name, "exit 0")
+ # Stop at the first changelog operation; never build/install.
+ marker = repo / "changelog-called"
+ stub = f'printf "%s" "$VERSION" > "{marker}"\nexit 73'
+ script(bindir / "gbp", stub)
+ script(repo / "contrib/ci/write-debian-changelog.sh", stub)
+ env = dict(os.environ, PATH=f"{bindir}:{os.environ['PATH']}")
+ result = run("bash", str(dest), "test-webui", cwd=repo, env=env, check=False)
+ if output:
+ self.assertEqual(result.returncode, 73, result.stderr)
+ self.assertEqual(marker.read_text(), output)
+ else:
+ self.assertNotEqual(result.returncode, 0, result.stderr)
+ self.assertFalse(marker.exists(), result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/contrib/ci/version.sh b/contrib/ci/version.sh
@@ -1,17 +1,6 @@
#!/bin/sh
-set -ex
-
-BRANCH=$(git name-rev --name-only HEAD)
-if [ -z "${BRANCH}" ]; then
- exit 1
-else
- # "Unshallow" our checkout, but only our current branch, and exclude the submodules.
- git fetch --no-recurse-submodules --tags --depth=1000 origin "${BRANCH}"
- RECENT_VERSION_TAG=$(git describe --tags --match 'v*.*.*' --exclude '*-dev*' --always --abbrev=0 HEAD || exit 1)
- commits="$(git rev-list ${RECENT_VERSION_TAG}..HEAD --count)"
- if [ "${commits}" = "0" ]; then
- git describe --tag HEAD | sed -r 's/^v//' || exit 1
- else
- echo $(echo ${RECENT_VERSION_TAG} | sed -r 's/^v//')-${commits}-$(git rev-parse --short=8 HEAD)
- fi
-fi
+# This file is in the public domain.
+# Print the Debian CI version; diagnostics go to stderr.
+set -eu
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+exec python3 "$script_dir/debian-version.py" "$@"