donau

Donation authority for GNU Taler (experimental)
Log | Files | Refs | Submodules | README | LICENSE

commit aa370576f24e9d673ec0ddb37fc7808f5a247e1c
parent 29bdf7af411210a99c7e9c1bd7b3ddd2f13b5cd2
Author: Florian Dold <dold@taler.net>
Date:   Tue,  8 Sep 2026 22:11:31 +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:
Mcontrib/ci/Containerfile | 1+
Mcontrib/ci/debian-package-job.sh | 1+
Acontrib/ci/debian-version.py | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontrib/ci/test_version.py | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontrib/ci/version.sh | 14+++-----------
5 files changed, 334 insertions(+), 11 deletions(-)

diff --git a/contrib/ci/Containerfile b/contrib/ci/Containerfile @@ -7,6 +7,7 @@ RUN apt-get update -yqq && \ meson \ autopoint \ curl \ + python3 \ git \ libcurl4-gnutls-dev \ libgcrypt-dev \ diff --git a/contrib/ci/debian-package-job.sh b/contrib/ci/debian-package-job.sh @@ -14,6 +14,7 @@ mk-build-deps --install --tool='apt-get -o Debug::pkgProblemResolver=yes --no-in 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}" ./bootstrap 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/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,14 +1,6 @@ #!/bin/sh # This file is in the public domain. -# Determine the Debian package version from the full checkout and release tags. +# Print the Debian CI version; diagnostics go to stderr. set -eu - -tag=$(git describe --tags --match 'v*.*.*' --exclude '*-dev*' --abbrev=0 HEAD) -commits=$(git rev-list "${tag}..HEAD" --count) - -if [ "${commits}" = "0" ]; then - printf '%s\n' "${tag#v}" -else - revision=$(git rev-parse --short=8 HEAD) - printf '%s-%s-%s\n' "${tag#v}" "${commits}" "${revision}" -fi +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$script_dir/debian-version.py" "$@"