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:
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")
}