taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit 18ef68372e0ee690dd625bb99dc277bc12e1894c
parent 734a7a1965c2afa6f5b64eddeb0e734dcc88b152
Author: Florian Dold <dold@taler.net>
Date:   Sun,  6 Sep 2026 18:16:07 +0200

taler-rust: derive program version from Git

Use the Git description for service versions instead of combining the
Cargo manifest version with a commit hash. Fall back to a root .version
file for source archives and track shared refs in linked worktrees.

Diffstat:
M.gitignore | 4++--
MREADME.md | 12++++++++++++
Abuild-system/test_version.py | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-build/build.rs | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcommon/taler-build/src/lib.rs | 17++---------------
5 files changed, 307 insertions(+), 51 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,4 +1,5 @@ .env +/.version .ci *.mk configure @@ -16,4 +17,4 @@ debian/*.substvars debian/*debhelper* postgres-language-server.jsonc *.p8 -*.ts -\ No newline at end of file +*.ts diff --git a/README.md b/README.md @@ -35,6 +35,18 @@ Setup documentation can be found [here](https://docs.taler.net/taler-cyclos-manu ## Getting Started for Development +Program versions come from `git describe --tags --always --abbrev=8` at build +time, independently of Cargo manifest versions. Git takes precedence over a +`.version` file in the checkout. Before exporting a source archive, run: + +```sh +git describe --tags --always --abbrev=8 > .version +``` + +Include `.version` at the archive root. Builds without Git metadata use that +file and fail if it is missing or does not contain a nonempty single-line +version. + Any Rust toolchain on `$PATH` will do, distribution packages included; `rustup` is not required. The tree needs **rustc >= 1.93**, so on Debian stable install it from backports (`sudo apt install -t trixie-backports rustc cargo`). diff --git a/build-system/test_version.py b/build-system/test_version.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 + +# This file is part of TALER +# Copyright (C) 2026 Taler Systems SA +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License along with +# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +"""Exercise the real build script with dependency-free Cargo/Git fixtures.""" + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CRATE = Path("common/taler-build") + + +class VersionTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="program-version-") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name) + self.repo = self.base / "source" + self.env = os.environ.copy() + self.env.update( + { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Version Test", + "GIT_AUTHOR_EMAIL": "version@example.invalid", + "GIT_COMMITTER_NAME": "Version Test", + "GIT_COMMITTER_EMAIL": "version@example.invalid", + "CARGO_TARGET_DIR": str(self.base / "target"), + } + ) + for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"): + self.env.pop(name, None) + crate = self.repo / CRATE + (crate / "src").mkdir(parents=True) + shutil.copy(ROOT / CRATE / "build.rs", crate / "build.rs") + (crate / "Cargo.toml").write_text( + '[package]\nname = "version-probe"\nversion = "0.0.0"\nedition = "2024"\n' + ) + (crate / "src/main.rs").write_text( + 'fn main() { println!("{}", env!("BUILD_VERSION")); }\n' + ) + (self.repo / ".gitignore").write_text("target\nCargo.lock\n.version\n") + + def command(self, *args, cwd=None, check=True): + result = subprocess.run( + args, + cwd=cwd or self.repo, + env=self.env, + text=True, + capture_output=True, + check=False, + ) + if check and result.returncode: + self.fail(f"{args}:\n{result.stdout}\n{result.stderr}") + return result + + def git(self, *args, repo=None): + return self.command("git", *args, cwd=repo).stdout.strip() + + def initialize(self): + self.git("init", "-b", "main") + self.git("add", ".") + self.git("commit", "-m", "Initial source") + + def build(self, repo=None, check=True): + return self.command( + "cargo", + "build", + "--offline", + "--verbose", + "--manifest-path", + str((repo or self.repo) / CRATE / "Cargo.toml"), + cwd=repo, + check=check, + ) + + def assert_version(self, expected, repo=None): + result = self.build(repo) + actual = self.command( + str(self.base / "target/debug/version-probe") + ).stdout.strip() + self.assertEqual(expected, actual) + return result + + def description(self, repo=None): + return self.git("describe", "--tags", "--always", "--abbrev=8", repo=repo) + + def test_release_development_tags_and_untagged_commits(self): + self.initialize() + self.assert_version(self.git("rev-parse", "--short=8", "HEAD")) + self.git("tag", "-a", "v1.2.3", "-m", "Release") + self.assert_version("v1.2.3") + self.git("commit", "--allow-empty", "-m", "Next commit") + self.assert_version(self.description()) + self.git("tag", "v1.2.4-dev.1") + self.assert_version("v1.2.4-dev.1") + # Dirty files do not change the commit version. + (self.repo / ".gitignore").write_text("target\nCargo.lock\n.version\nextra\n") + self.assert_version("v1.2.4-dev.1") + + def test_git_takes_precedence_over_stamp(self): + self.initialize() + (self.repo / ".version").write_text("v0.0.1\n") + self.assert_version(self.description()) + + def test_incremental_branch_and_packed_tag_changes(self): + self.initialize() + self.git("tag", "v1.0.0") + self.assert_version("v1.0.0") + unchanged = self.assert_version("v1.0.0") + self.assertIn("Fresh version-probe", unchanged.stderr) + self.git("switch", "-c", "next") + self.git("commit", "--allow-empty", "-m", "Next commit") + self.assert_version(self.description()) + self.git("tag", "v1.1.0") + self.assert_version("v1.1.0") + self.git("pack-refs", "--all", "--prune") + self.assert_version("v1.1.0") + self.git("tag", "-d", "v1.1.0") + self.assert_version(self.description()) + self.git("switch", "main") + self.assert_version("v1.0.0") + self.git("switch", "--detach", "next") + self.assert_version(self.description()) + + def test_linked_worktree_uses_shared_refs(self): + self.initialize() + self.git("tag", "v1.0.0") + worktree = self.base / "worktree" + self.git("worktree", "add", "-b", "linked", str(worktree)) + self.assert_version("v1.0.0", worktree) + self.git("commit", "--allow-empty", "-m", "Worktree commit", repo=worktree) + self.assert_version(self.description(worktree), worktree) + self.git("tag", "v1.1.0", repo=worktree) + self.assert_version("v1.1.0", worktree) + self.git("pack-refs", "--all", "--prune") + self.assert_version("v1.1.0", worktree) + self.git("tag", "-d", "v1.1.0") + self.assert_version(self.description(worktree), worktree) + + def test_shallow_tagged_and_untagged_clones(self): + self.initialize() + self.git("tag", "v1.0.0") + clone = self.base / "clone" + self.git( + "clone", "--depth=1", "--branch=v1.0.0", self.repo.as_uri(), str(clone) + ) + self.assert_version("v1.0.0", clone) + self.git("commit", "--allow-empty", "-m", "Unreachable tag") + shallow = self.base / "shallow" + self.git("clone", "--depth=1", "--no-tags", self.repo.as_uri(), str(shallow)) + self.assert_version( + self.git("rev-parse", "--short=8", "HEAD", repo=shallow), shallow + ) + + def test_archive_fallback_and_incremental_stamp_changes(self): + (self.repo / ".version").write_text(" v2.0.0\n") + self.assert_version("v2.0.0") + (self.repo / ".version").write_text("v2.0.1\n") + self.assert_version("v2.0.1") + self.initialize() + self.assert_version(self.description()) + (self.repo / ".git").rename(self.base / "saved-git") + self.assert_version("v2.0.1") + + def test_archive_does_not_use_enclosing_repository(self): + self.git("init", "-b", "main", repo=self.base) + self.git("commit", "--allow-empty", "-m", "Unrelated project", repo=self.base) + self.git("tag", "v9.9.9", repo=self.base) + (self.repo / ".version").write_text("v2.0.0\n") + self.assert_version("v2.0.0") + (self.repo / ".version").unlink() + result = self.build(check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn("cannot determine program version", result.stderr) + + def test_missing_or_invalid_archive_stamp_fails(self): + result = self.build(check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn("Source archives must contain a .version file", result.stderr) + for value in ("\n ", "v1.0.0\nv2.0.0", "v1.0.0\rjunk", "v1.0.0\0junk"): + with self.subTest(value=value): + (self.repo / ".version").write_text(value) + result = self.build(check=False) + self.assertNotEqual(0, result.returncode) + self.assertIn( + "program version must be a nonempty single line", result.stderr + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/common/taler-build/build.rs b/common/taler-build/build.rs @@ -14,49 +14,97 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; -fn run_command(command: &str, args: &[&str]) -> Result<String, String> { - let output = Command::new(command) +fn git(root: &Path, args: &[&str]) -> Result<String, String> { + let output = Command::new("git") .args(args) + .current_dir(root) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") .output() - .map_err(|e| format!("Failed to execute {}: {}", command, e))?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } else { - Err(format!( - "Command failed: {} {:?}\nStderr: {}", - command, - args, - String::from_utf8_lossy(&output.stderr) - )) + .map_err(|e| format!("could not run git: {e}"))?; + if !output.status.success() { + return Err(format!( + "git {}: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); } + String::from_utf8(output.stdout) + .map(|value| value.trim().to_owned()) + .map_err(|e| format!("invalid Git output: {e}")) } -fn main() -> Result<(), String> { - // Get project git dir - let git_dir = run_command("git", &["rev-parse", "--git-dir"])?; - - // Watch HEAD - let head_path = format!("{git_dir}/HEAD"); - println!("cargo:rerun-if-changed={head_path}"); - - // Watch HEAD ref - let head_content = - std::fs::read_to_string(head_path).map_err(|e| format!("Failed to read git HEAD: {e}"))?; - if let Some(ref_path) = head_content.strip_prefix("ref: ") { - println!("cargo:rerun-if-changed={git_dir}/{ref_path}"); - } - - // Get the short commit hash - let commit_hash = run_command("git", &["rev-parse", "--short", "HEAD"])?; +fn watch(path: &Path) { + println!("cargo:rerun-if-changed={}", path.display()); +} - // Set the environment variable VERSION - println!("cargo:rustc-env=GIT_HASH={}", commit_hash); +fn git_version(root: &Path) -> Result<String, String> { + // An archive can be unpacked inside another checkout. Only use our own Git + // metadata, including the .git file used by linked worktrees/submodules. + let marker = root.join(".git"); + if !marker.exists() { + return Err("source tree has no Git metadata".into()); + } + if marker.is_file() { + watch(&marker); + } + let toplevel = git(root, &["rev-parse", "--show-toplevel"])?; + if Path::new(&toplevel) + .canonicalize() + .map_err(|e| e.to_string())? + != root + { + return Err("Git metadata belongs to another source tree".into()); + } - // Watch the build script also - println!("cargo:rerun-if-changed=build.rs"); + // Resolve paths through Git: worktree HEAD is private, while refs and + // packed-refs live in the common Git directory. Watching all loose refs + // also catches new/deleted tags, even when HEAD itself does not move. + for name in ["HEAD", "refs", "packed-refs", "shallow"] { + let path = PathBuf::from(git( + root, + &["rev-parse", "--path-format=absolute", "--git-path", name], + )?); + // refs exists even when empty. Packing refs removes loose files there, + // which reruns this script and adds the newly created packed-refs file. + if path.exists() || name == "HEAD" { + watch(&path); + } + } + git(root, &["describe", "--tags", "--always", "--abbrev=8"]) +} +fn main() -> Result<(), String> { + let manifest = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").ok_or("CARGO_MANIFEST_DIR is missing")?); + let root = manifest + .join("../..") + .canonicalize() + .map_err(|e| format!("could not locate source root: {e}"))?; + watch(&manifest.join("build.rs")); + let version = match git_version(&root) { + Ok(version) => version, + Err(git_error) => { + let path = root.join(".version"); + watch(&path); + // Recheck Git availability on later builds of an extracted tree. + watch(&root.join(".git")); + fs::read_to_string(&path).map_err(|e| format!( + "cannot determine program version: {git_error}; could not read {}: {e}. Source archives must contain a .version file", + path.display() + ))? + } + }; + let version = version.trim(); + if version.is_empty() || version.contains(['\n', '\r', '\0']) { + return Err("program version must be a nonempty single line".into()); + } + println!("cargo:rustc-env=BUILD_VERSION={version}"); Ok(()) } diff --git a/common/taler-build/src/lib.rs b/common/taler-build/src/lib.rs @@ -14,20 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::sync::LazyLock; - -/// Taler component version format -static LONG_VERSION: LazyLock<String> = LazyLock::new(|| { - let version = env!("CARGO_PKG_VERSION"); - let git_hash = option_env!("GIT_HASH"); - if let Some(hash) = git_hash { - format!("v{version}-git-{hash}") - } else { - format!("v{version}") - } -}); - -/// Taler component version format +/// Git description of the build, or the version stamped into a source archive. pub fn long_version() -> &'static str { - &LONG_VERSION + env!("BUILD_VERSION") }