commit 9d789578143ad101528fcda656b6f9583dc3430d
parent a7e1ff5e3c12b65022bfaa950b61d9319f57c826
Author: Florian Dold <dold@taler.net>
Date: Sun, 6 Sep 2026 14:56:50 +0200
tools: add taler-repos workspace manager
Build and install components in dependency order using an embedded map.
Report local versions and create release or development tags for untagged
HEADs, with a dry run for reviewing the proposed tags.
Allow selecting the workspace directory with --root.
Diffstat:
5 files changed, 749 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -36,6 +36,7 @@ regional-currency-ng/ -- Ansible deployment for a regional currency
sign-gnu-artefact/ -- shell script for making releases and preparing upload to ftp.gnu.org
splitops/ -- shell for multi-sig access control to lock down root on production systems
systemd-services/ -- service definitions for buildbot (master and slaves)
+tools/ -- [standalone tools for local Taler workspaces](tools/README.md)
weblate/ -- FIXME
worker-codecoverage/ -- scripts for running code coverage (currently down!)
worker-linkchecker/ -- buildbot worker scripts to check for broken links
diff --git a/tools/.gitignore b/tools/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/tools/README.md b/tools/README.md
@@ -0,0 +1,44 @@
+# Local workspace tools
+
+`taler-repos` updates, builds, installs, tags, and reports versions of the local
+Taler stack. It is a standalone Python script using only the standard library;
+it can be copied elsewhere and does not read deployment configuration files.
+
+Run it from the directory containing the component repositories, or pass
+`--root` before the command:
+
+```sh
+./taler-deployment/tools/taler-repos versions
+./taler-deployment/tools/taler-repos build
+./taler-deployment/tools/taler-repos bump --dry-run
+./taler-deployment/tools/taler-repos bump --dev --dry-run
+/path/to/taler-repos --root ~/taler versions
+```
+
+`build` requires clean worktrees with upstream branches, pulls all components
+with `--ff-only`, then runs bootstrap, configure, make, and make install in
+dependency order. The installation prefix is `~/local`. Dependencies are
+embedded in `BUILD_DEPENDENCIES`, based on `packaging/ng/packages.toml`; update
+the map when component dependencies change. External dependencies, such as
+libmicrohttpd, must already be installed.
+
+`bump` creates lightweight local tags on each component's HEAD. Both modes
+skip HEADs with any `v*` tag. Untagged components must have clean worktrees and
+an existing local `vX.Y.Z` or `vX.Y.Z-dev.N` tag. The highest supported version
+across all local branches determines the next version:
+
+| Highest version | `bump` | `bump --dev` |
+| --- | --- | --- |
+| `v1.2.3` | `v1.2.4` | `v1.2.4-dev.1` |
+| `v1.2.4-dev.4` | `v1.2.4` | `v1.2.4-dev.5` |
+
+All components are validated before any tags are created. `--dry-run` shows
+the proposed tags without creating them. Bumping does not fetch, pull, push,
+or edit version files. If tag creation fails, the command stops and reports
+any tags it already created.
+
+Run the tests from the deployment repository root with Python 3.11 or newer:
+
+```sh
+python3 -m unittest discover -s tools -v
+```
diff --git a/tools/taler-repos b/tools/taler-repos
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+
+# This file is in the public domain.
+
+"""Update, build, install, tag, and report versions of the local Taler stack."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shlex
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path.cwd()
+REPOSITORIES = (
+ "gnunet",
+ "exchange",
+ "merchant",
+ "anastasis",
+ "challenger",
+ "donau",
+ "paivana",
+ "taler-mdb",
+ "libeufin",
+ "taler-merchant-demos",
+ "taler-typescript-core",
+ "taler-rust",
+)
+# Local build dependencies from taler-deployment/packaging/ng/packages.toml.
+# Keep this map embedded so no deployment checkout is needed at runtime.
+# Repositories omitted here have no dependencies within REPOSITORIES;
+# external dependencies such as libmicrohttpd must already be installed.
+BUILD_DEPENDENCIES = {
+ "exchange": ("gnunet",),
+ "merchant": ("gnunet", "exchange", "donau"),
+ "anastasis": ("gnunet", "merchant"),
+ "challenger": ("exchange",),
+ "donau": ("gnunet", "exchange"),
+ "paivana": ("merchant", "exchange", "gnunet"),
+ "taler-mdb": ("gnunet", "exchange", "merchant"),
+ "libeufin": ("taler-typescript-core",),
+}
+# The packaging tools support releases and numbered dev prereleases.
+VERSION_TAG = re.compile(
+ r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
+ r"(?:-dev\.(0|[1-9][0-9]*))?"
+)
+
+
+class BuildError(RuntimeError):
+ """A repository command or preflight check failed."""
+
+
+def capture(repo: Path, *command: str) -> str:
+ try:
+ result = subprocess.run(
+ command,
+ cwd=repo,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ except OSError as exc:
+ rendered = shlex.join(command)
+ raise BuildError(f"{repo.name}: could not run '{rendered}': {exc}") from exc
+ if result.returncode != 0:
+ detail = result.stderr.strip() or result.stdout.strip()
+ rendered = shlex.join(command)
+ raise BuildError(f"{repo.name}: '{rendered}' failed: {detail}")
+ return result.stdout.strip()
+
+
+def run(repo: Path, *command: str) -> None:
+ print(f"\n[{repo.name}] $ {shlex.join(command)}", flush=True)
+ try:
+ subprocess.run(command, cwd=repo, check=True)
+ except subprocess.CalledProcessError as exc:
+ rendered = shlex.join(command)
+ raise BuildError(
+ f"{repo.name}: '{rendered}' exited with status {exc.returncode}"
+ ) from exc
+ except OSError as exc:
+ rendered = shlex.join(command)
+ raise BuildError(f"{repo.name}: could not run '{rendered}': {exc}") from exc
+
+
+def repository(name: str) -> Path:
+ repo = ROOT / name
+ if not repo.is_dir():
+ raise BuildError(f"{name}: repository directory not found at {repo}")
+ top = Path(capture(repo, "git", "rev-parse", "--show-toplevel")).resolve()
+ if top != repo.resolve():
+ raise BuildError(f"{name}: {repo} is not a top-level Git repository")
+ return repo
+
+
+def preflight(repos: list[Path]) -> None:
+ problems: list[str] = []
+ for repo in repos:
+ try:
+ capture(repo, "git", "rev-parse", "--verify", "@{upstream}")
+ status = capture(
+ repo, "git", "status", "--porcelain", "--untracked-files=all"
+ )
+ if status:
+ problems.append(f"{repo.name}: worktree is not clean")
+ except BuildError as exc:
+ problems.append(str(exc))
+ if problems:
+ joined = "\n - ".join(problems)
+ raise BuildError(f"preflight failed:\n - {joined}")
+
+
+def build_order() -> list[str]:
+ """Order the local repositories using the embedded dependency graph."""
+ for name, dependencies in BUILD_DEPENDENCIES.items():
+ if name not in REPOSITORIES:
+ raise BuildError(f"unknown repository in build dependencies: {name}")
+ for dependency in dependencies:
+ if dependency not in REPOSITORIES:
+ raise BuildError(f"{name}: unknown build dependency {dependency}")
+
+ ordered: list[str] = []
+ visiting: list[str] = []
+
+ def visit(name: str) -> None:
+ if name in ordered:
+ return
+ if name in visiting:
+ cycle = visiting[visiting.index(name) :] + [name]
+ raise BuildError(f"repository dependency cycle: {' -> '.join(cycle)}")
+ visiting.append(name)
+ for dependency in REPOSITORIES:
+ if dependency in BUILD_DEPENDENCIES.get(name, ()):
+ visit(dependency)
+ visiting.pop()
+ ordered.append(name)
+
+ for name in REPOSITORIES:
+ visit(name)
+ return ordered
+
+
+def build() -> None:
+ repos = [repository(name) for name in build_order()]
+ preflight(repos)
+
+ print(f"Build order: {', '.join(repo.name for repo in repos)}", flush=True)
+ print("Preflight passed; updating all repositories.", flush=True)
+ for repo in repos:
+ run(repo, "git", "pull", "--ff-only")
+
+ home = Path.home()
+ configure = (
+ "./configure",
+ f"--prefix={home / 'local'}",
+ f"CFLAGS=-I{home / 'local' / 'include'}",
+ f"LDFLAGS=-L{home / 'local' / 'lib64'}",
+ )
+ jobs = str(os.cpu_count() or 1)
+
+ for repo in repos:
+ run(repo, "./bootstrap")
+ run(repo, *configure)
+ run(repo, "make", f"-j{jobs}")
+ run(repo, "make", "install")
+
+
+def versions() -> None:
+ for name in REPOSITORIES:
+ repo = repository(name)
+ tags = capture(repo, "git", "tag", "--points-at", "HEAD").splitlines()
+ version = ", ".join(sorted(tags))
+ if not version:
+ version = capture(repo, "git", "rev-parse", "--short=12", "HEAD")
+ dirty = capture(repo, "git", "status", "--porcelain", "--untracked-files=all")
+ suffix = " (dirty)" if dirty else ""
+ print(f"{name:<24} {version}{suffix}")
+
+
+def next_tag(tags: list[str], dev: bool) -> str:
+ """Advance the highest supported local version using SemVer precedence."""
+ versions: list[tuple[int, int, int, bool, int]] = []
+ for tag in tags:
+ match = VERSION_TAG.fullmatch(tag)
+ if match is None:
+ continue
+ major, minor, patch = (int(match[i]) for i in (1, 2, 3))
+ prerelease = match[4]
+ versions.append((major, minor, patch, prerelease is None, int(prerelease or 0)))
+ if not versions:
+ raise BuildError("no supported local version tag (vX.Y.Z or vX.Y.Z-dev.N)")
+
+ major, minor, patch, release, number = max(versions)
+ if release:
+ patch += 1
+ number = 0
+ suffix = f"-dev.{number + 1}" if dev else ""
+ return f"v{major}.{minor}.{patch}{suffix}"
+
+
+def bump(dev: bool = False, dry_run: bool = False) -> None:
+ """Validate the whole stack before creating lightweight local tags."""
+ changes: list[tuple[Path, str, str]] = []
+ problems: list[str] = []
+ for name in REPOSITORIES:
+ try:
+ repo = repository(name)
+ head = capture(repo, "git", "rev-parse", "--verify", "HEAD^{commit}")
+ head_tags = capture(
+ repo, "git", "tag", "--list", "v*", "--points-at", head
+ ).splitlines()
+ if head_tags:
+ print(f"{name:<24} skip: HEAD tagged {', '.join(sorted(head_tags))}")
+ continue
+ status = capture(
+ repo, "git", "status", "--porcelain", "--untracked-files=all"
+ )
+ if status:
+ raise BuildError("worktree is not clean")
+ tags = capture(repo, "git", "tag", "--list").splitlines()
+ tag = next_tag(tags, dev)
+ if tag in tags:
+ raise BuildError(f"tag {tag} already exists")
+ changes.append((repo, tag, head))
+ print(f"{name:<24} would tag {head[:12]} as {tag}")
+ except BuildError as exc:
+ detail = str(exc)
+ problems.append(
+ detail if detail.startswith(f"{name}:") else f"{name}: {detail}"
+ )
+
+ if problems:
+ joined = "\n - ".join(problems)
+ raise BuildError(f"bump preflight failed; no tags created:\n - {joined}")
+ if dry_run:
+ print("Dry run complete; no tags created.")
+ return
+
+ created: list[str] = []
+ for repo, tag, head in changes:
+ try:
+ # Override automatic signing so tags remain lightweight even when
+ # the user's Git configuration enables tag.gpgSign.
+ run(repo, "git", "-c", "tag.gpgSign=false", "tag", tag, head)
+ except BuildError as exc:
+ retained = ", ".join(created) or "none"
+ raise BuildError(
+ f"{exc}; tags already created and retained: {retained}"
+ ) from exc
+ created.append(f"{repo.name}:{tag}")
+ print(f"Created {len(created)} local tag(s).")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--root",
+ type=Path,
+ default=ROOT,
+ help="directory containing the component repositories (default: current directory)",
+ )
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ subparsers.add_parser("build", help="pull, build, and install the Taler stack")
+ subparsers.add_parser("versions", help="show each repository's tag or commit")
+ bump_parser = subparsers.add_parser(
+ "bump",
+ help="tag the next version of each untagged component HEAD",
+ description=(
+ "Create local lightweight tags from the highest local vX.Y.Z or "
+ "vX.Y.Z-dev.N version, across all branches. Skip HEADs with any v* tag. "
+ "By default, advance the patch release or promote a dev version to "
+ "its release. Does not fetch, pull, push, or edit version files."
+ ),
+ )
+ bump_parser.add_argument(
+ "--dev",
+ action="store_true",
+ help="increment dev.N, or start the next patch at dev.1; still skip tagged HEADs",
+ )
+ bump_parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="validate and show changes without tagging",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ global ROOT
+ args = parse_args()
+ ROOT = args.root.resolve()
+ try:
+ if args.command == "build":
+ build()
+ elif args.command == "bump":
+ bump(dev=args.dev, dry_run=args.dry_run)
+ else:
+ versions()
+ except BuildError as exc:
+ print(f"taler-repos: {exc}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/test_taler_repos.py b/tools/test_taler_repos.py
@@ -0,0 +1,393 @@
+# This file is in the public domain.
+
+"""Run from the repository root with python3 -m unittest discover -s tools -v."""
+
+import importlib.machinery
+import importlib.util
+import io
+import os
+import subprocess
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from unittest.mock import call, patch
+
+SCRIPT = Path(__file__).with_name("taler-repos")
+loader = importlib.machinery.SourceFileLoader("taler_repos", str(SCRIPT))
+spec = importlib.util.spec_from_loader(loader.name, loader)
+taler_repos = importlib.util.module_from_spec(spec)
+loader.exec_module(taler_repos)
+
+
+class BuildOrderTests(unittest.TestCase):
+ EXPECTED = (
+ "gnunet",
+ "exchange",
+ "donau",
+ "merchant",
+ "anastasis",
+ "challenger",
+ "paivana",
+ "taler-mdb",
+ "taler-typescript-core",
+ "libeufin",
+ "taler-merchant-demos",
+ "taler-rust",
+ )
+
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.enterContext(patch.object(taler_repos, "ROOT", self.root))
+
+ def test_order_without_deployment_checkout(self):
+ self.assertFalse((self.root / "taler-deployment").exists())
+ self.assertEqual(taler_repos.build_order(), list(self.EXPECTED))
+
+ def test_all_dependencies_precede_consumers_when_repository_order_changes(self):
+ components = tuple(reversed(taler_repos.REPOSITORIES))
+ with patch.object(taler_repos, "REPOSITORIES", components):
+ order = taler_repos.build_order()
+ for name, dependencies in taler_repos.BUILD_DEPENDENCIES.items():
+ for dependency in dependencies:
+ self.assertLess(order.index(dependency), order.index(name))
+ self.assertEqual(order[0], "taler-rust")
+
+ def test_cycle_reports_the_components(self):
+ with (
+ patch.dict(taler_repos.BUILD_DEPENDENCIES, {"gnunet": ("merchant",)}),
+ self.assertRaisesRegex(
+ taler_repos.BuildError,
+ "cycle: gnunet -> merchant -> gnunet",
+ ),
+ ):
+ taler_repos.build_order()
+
+ def test_unknown_repository_or_dependency(self):
+ for entry, error in (
+ ({"missing": ()}, "unknown repository in build dependencies: missing"),
+ ({"gnunet": ("missing",)}, "gnunet: unknown build dependency missing"),
+ ):
+ with (
+ self.subTest(entry=entry),
+ patch.dict(taler_repos.BUILD_DEPENDENCIES, entry),
+ self.assertRaisesRegex(taler_repos.BuildError, error),
+ ):
+ taler_repos.build_order()
+
+ def test_build_pulls_every_repository_before_building_in_order(self):
+ with (
+ patch.object(
+ taler_repos, "repository", side_effect=lambda n: self.root / n
+ ),
+ patch.object(taler_repos, "preflight") as preflight,
+ patch.object(taler_repos, "run") as run,
+ redirect_stdout(io.StringIO()),
+ ):
+ taler_repos.build()
+ repos = [self.root / name for name in self.EXPECTED]
+ preflight.assert_called_once_with(repos)
+ calls = run.call_args_list
+ self.assertEqual(
+ calls[: len(repos)], [call(r, "git", "pull", "--ff-only") for r in repos]
+ )
+ builds = calls[len(repos) :]
+ self.assertEqual(len(builds), 4 * len(repos))
+ for index, repo in enumerate(repos):
+ commands = builds[4 * index : 4 * index + 4]
+ self.assertEqual(commands[0], call(repo, "./bootstrap"))
+ self.assertEqual(commands[1].args[:2], (repo, "./configure"))
+ self.assertEqual(commands[2].args[:2], (repo, "make"))
+ self.assertEqual(commands[3], call(repo, "make", "install"))
+
+ def test_invalid_dependencies_stop_before_repository_commands(self):
+ with (
+ patch.dict(taler_repos.BUILD_DEPENDENCIES, {"gnunet": ("missing",)}),
+ patch.object(taler_repos, "repository") as repository,
+ self.assertRaises(taler_repos.BuildError),
+ ):
+ taler_repos.build()
+ repository.assert_not_called()
+
+
+class WorkspaceTests(unittest.TestCase):
+ def test_default_workspace_is_the_current_directory(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ result = subprocess.run(
+ [
+ "python3",
+ "-B",
+ "-c",
+ "import runpy, sys; print(runpy.run_path(sys.argv[1])['ROOT'])",
+ str(SCRIPT),
+ ],
+ cwd=temporary,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(Path(result.stdout.strip()), Path(temporary))
+
+ def test_explicit_workspace_is_used_from_another_directory(self):
+ with (
+ tempfile.TemporaryDirectory() as temporary,
+ patch.object(taler_repos, "ROOT", Path.cwd()),
+ patch("sys.argv", [str(SCRIPT), "--root", temporary, "versions"]),
+ patch.object(taler_repos, "versions") as versions,
+ ):
+ self.assertEqual(taler_repos.main(), 0)
+ versions.assert_called_once_with()
+ self.assertEqual(taler_repos.ROOT, Path(temporary).resolve())
+
+
+class NextTagTests(unittest.TestCase):
+ def test_semver_transitions(self):
+ cases = [
+ (["v1.2.3"], "v1.2.4", "v1.2.4-dev.1"),
+ (["v1.2.4-dev.4"], "v1.2.4", "v1.2.4-dev.5"),
+ (["v1.2.4-dev.0"], "v1.2.4", "v1.2.4-dev.1"),
+ (["v1.2.4", "v1.2.4-dev.99"], "v1.2.5", "v1.2.5-dev.1"),
+ (
+ ["v1.2.9", "v1.2.10-dev.9", "v1.2.10-dev.10"],
+ "v1.2.10",
+ "v1.2.10-dev.11",
+ ),
+ (["v1.9.99", "v1.10.0", "v0.99.99"], "v1.10.1", "v1.10.1-dev.1"),
+ (["v9.99.99", "v10.0.0"], "v10.0.1", "v10.0.1-dev.1"),
+ (["v0.0.0"], "v0.0.1", "v0.0.1-dev.1"),
+ (
+ ["v1.2.3", "deb-v9.9.9-1", "v9.9.9-rc.1", "v2.0", "v3.0.0+build"],
+ "v1.2.4",
+ "v1.2.4-dev.1",
+ ),
+ ]
+ for tags, stable, dev in cases:
+ with self.subTest(tags=tags):
+ self.assertEqual(taler_repos.next_tag(tags, False), stable)
+ self.assertEqual(taler_repos.next_tag(list(reversed(tags)), True), dev)
+
+ def test_missing_supported_version(self):
+ for tags in (
+ [],
+ [
+ "release",
+ "deb-v1.2.3-1",
+ "v01.2.3",
+ "v1.02.3",
+ "v1.2.03",
+ "v1.2.3-dev.01",
+ ],
+ ):
+ with (
+ self.subTest(tags=tags),
+ self.assertRaisesRegex(
+ taler_repos.BuildError, "no supported local version"
+ ),
+ ):
+ taler_repos.next_tag(tags, False)
+
+
+class BumpTests(unittest.TestCase):
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.enterContext(patch.object(taler_repos, "ROOT", self.root))
+ self.enterContext(patch.object(taler_repos, "REPOSITORIES", ("one", "two")))
+ self.enterContext(
+ patch.dict(
+ os.environ,
+ {
+ "GIT_CONFIG_NOSYSTEM": "1",
+ "GIT_CONFIG_GLOBAL": os.devnull,
+ },
+ )
+ )
+ for name in taler_repos.REPOSITORIES:
+ repo = self.root / name
+ repo.mkdir()
+ self.git(name, "init", "--quiet", "--initial-branch=main", "--template=")
+ self.git(name, "config", "user.name", "Taler Repos Test")
+ self.git(name, "config", "user.email", "test@example.invalid")
+ self.git(name, "config", "commit.gpgSign", "false")
+ self.git(name, "commit", "--quiet", "--allow-empty", "-m", "Initial commit")
+ self.git(name, "tag", "v1.2.3")
+ self.git(name, "commit", "--quiet", "--allow-empty", "-m", "Next commit")
+
+ def git(self, name, *args):
+ return subprocess.run(
+ ["git", *args],
+ cwd=self.root / name,
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+
+ def bump(self, **kwargs):
+ output = io.StringIO()
+ with redirect_stdout(output):
+ taler_repos.bump(**kwargs)
+ return output.getvalue()
+
+ def refs(self):
+ return {name: self.git(name, "show-ref") for name in taler_repos.REPOSITORIES}
+
+ def test_creates_lightweight_tags_without_upstream_and_skips_on_repeat(self):
+ for name in taler_repos.REPOSITORIES:
+ self.git(name, "config", "tag.gpgSign", "true")
+ output = self.bump()
+ self.assertIn("Created 2 local tag(s)", output)
+ for name in taler_repos.REPOSITORIES:
+ self.assertEqual(self.git(name, "cat-file", "-t", "v1.2.4"), "commit")
+ self.assertEqual(
+ self.git(name, "rev-parse", "v1.2.4"),
+ self.git(name, "rev-parse", "HEAD"),
+ )
+ before = self.refs()
+ self.assertIn("skip: HEAD tagged v1.2.4", self.bump())
+ self.assertEqual(self.refs(), before)
+
+ def test_dev_bumps_each_components_own_version(self):
+ self.git("two", "tag", "v2.0.0-dev.9", "HEAD~")
+ self.bump(dev=True)
+ self.assertEqual(self.git("one", "tag", "--points-at", "HEAD"), "v1.2.4-dev.1")
+ self.assertEqual(self.git("two", "tag", "--points-at", "HEAD"), "v2.0.0-dev.10")
+
+ def test_both_modes_skip_all_v_tags_including_annotated_and_unsupported(self):
+ self.git("one", "tag", "v-unusual-format")
+ self.git("two", "tag", "-a", "v1.2.4-dev.1", "-m", "Development version")
+ (self.root / "two" / "untracked").write_text("dirty tagged HEAD is skipped")
+ before = self.refs()
+ for dev in (False, True):
+ with self.subTest(dev=dev):
+ output = self.bump(dev=dev)
+ self.assertIn("skip: HEAD tagged v-unusual-format", output)
+ self.assertIn("skip: HEAD tagged v1.2.4-dev.1", output)
+ self.assertEqual(self.refs(), before)
+
+ def test_non_version_tag_does_not_skip_head(self):
+ self.git("one", "tag", "checkpoint")
+ self.bump()
+ self.assertEqual(
+ self.git("one", "rev-parse", "v1.2.4"), self.git("one", "rev-parse", "HEAD")
+ )
+
+ def test_highest_version_can_be_on_another_branch(self):
+ self.git("one", "checkout", "--quiet", "-b", "other")
+ self.git("one", "commit", "--quiet", "--allow-empty", "-m", "Other branch")
+ self.git("one", "tag", "-a", "v1.10.0-dev.9", "-m", "Other version")
+ self.git("one", "checkout", "--quiet", "main")
+ self.bump()
+ self.assertEqual(
+ self.git("one", "rev-parse", "v1.10.0"),
+ self.git("one", "rev-parse", "HEAD"),
+ )
+
+ def test_dry_run_leaves_all_refs_unchanged(self):
+ before = self.refs()
+ for dev, expected in ((False, "v1.2.4"), (True, "v1.2.4-dev.1")):
+ with self.subTest(dev=dev):
+ output = self.bump(dev=dev, dry_run=True)
+ self.assertIn("as " + expected, output)
+ self.assertIn("Dry run complete; no tags created.", output)
+ self.assertEqual(self.refs(), before)
+
+ def test_dirty_repository_prevents_all_tagging_including_dry_run(self):
+ (self.root / "two" / "untracked").write_text("uncommitted")
+ before = self.refs()
+ for dry_run in (False, True):
+ with self.subTest(dry_run=dry_run):
+ with self.assertRaisesRegex(
+ taler_repos.BuildError, "two: worktree is not clean"
+ ):
+ self.bump(dry_run=dry_run)
+ self.assertEqual(self.refs(), before)
+
+ def test_missing_baseline_prevents_all_tagging(self):
+ self.git("two", "tag", "-d", "v1.2.3")
+ before = self.refs()
+ with self.assertRaisesRegex(
+ taler_repos.BuildError, "two: no supported local version"
+ ):
+ self.bump()
+ self.assertEqual(self.refs(), before)
+
+ def test_preflight_tag_collision_prevents_all_tagging(self):
+ # A normally calculated next tag is necessarily newer than all local
+ # versions; inject a collision to exercise the explicit safety check.
+ before = self.refs()
+ with (
+ patch.object(taler_repos, "next_tag", side_effect=["v1.2.4", "v1.2.3"]),
+ self.assertRaisesRegex(
+ taler_repos.BuildError, "two: tag v1.2.3 already exists"
+ ),
+ ):
+ self.bump()
+ self.assertEqual(self.refs(), before)
+
+ def test_creation_collision_retains_prior_tags_and_never_overwrites(self):
+ original = self.git("two", "rev-parse", "HEAD~")
+
+ def race(repo, *command):
+ if repo.name == "two":
+ self.git("two", "tag", "v1.2.4", original)
+ # Capture the expected Git error while executing the real tag command.
+ taler_repos.capture(repo, *command)
+
+ with (
+ patch.object(taler_repos, "run", side_effect=race),
+ self.assertRaisesRegex(taler_repos.BuildError, "retained: one:v1.2.4"),
+ ):
+ self.bump()
+ self.assertEqual(
+ self.git("one", "rev-parse", "v1.2.4"), self.git("one", "rev-parse", "HEAD")
+ )
+ self.assertEqual(self.git("two", "rev-parse", "v1.2.4"), original)
+
+ def test_tags_the_inspected_commit_when_head_moves(self):
+ original = self.git("one", "rev-parse", "HEAD")
+ run = taler_repos.run
+
+ def advance(repo, *command):
+ if repo.name == "one":
+ self.git(
+ "one",
+ "commit",
+ "--quiet",
+ "--allow-empty",
+ "-m",
+ "Concurrent commit",
+ )
+ run(repo, *command)
+
+ with patch.object(taler_repos, "run", side_effect=advance):
+ self.bump()
+ self.assertEqual(self.git("one", "rev-parse", "v1.2.4"), original)
+ self.assertNotEqual(self.git("one", "rev-parse", "HEAD"), original)
+
+ def test_cli_dispatch_and_error_prefix(self):
+ before = self.refs()
+ with (
+ patch("sys.argv", [str(SCRIPT), "bump", "--dev", "--dry-run"]),
+ redirect_stdout(io.StringIO()) as output,
+ ):
+ self.assertEqual(taler_repos.main(), 0)
+ self.assertIn("v1.2.4-dev.1", output.getvalue())
+ self.assertEqual(self.refs(), before)
+ (self.root / "two" / "dirty").touch()
+ with (
+ patch("sys.argv", [str(SCRIPT), "bump"]),
+ redirect_stdout(io.StringIO()),
+ redirect_stderr(io.StringIO()) as error,
+ ):
+ self.assertEqual(taler_repos.main(), 1)
+ self.assertTrue(
+ error.getvalue().startswith("taler-repos: bump preflight failed")
+ )
+ self.assertEqual(self.refs(), before)
+
+
+if __name__ == "__main__":
+ unittest.main()