taler-deployment

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

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:
MREADME.md | 1+
Atools/.gitignore | 1+
Atools/README.md | 44++++++++++++++++++++++++++++++++++++++++++++
Atools/taler-repos | 310+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/test_taler_repos.py | 393+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()