commit 0b389251f25315d8aa865c2a9327ed842781717e
parent db8a5f47ee00f822c59272dbd41d4c1cd6b25dad
Author: Florian Dold <dold@taler.net>
Date: Sun, 6 Sep 2026 18:16:07 +0200
robocop: derive program version from Git
Embed the Git description at build time instead of the hardcoded program
version. Fall back to a root .version file for source archives and track
Git refs so incremental builds refresh the embedded version.
Diffstat:
5 files changed, 337 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
@@ -1,4 +1,5 @@
.rustc_info.json
+/.version
Cargo.lock
debian/.debhelper/
debian/cargo/
diff --git a/README.md b/README.md
@@ -16,6 +16,19 @@ Then make sure you have Rust and Cargo installed.
## Install and run
+The program version comes from `git describe --tags --always --abbrev=8` at
+build time. Git takes precedence over a `.version` file in the checkout.
+Before exporting a source archive, record the version with:
+
+```sh
+git describe --tags --always --abbrev=8 > .version
+```
+
+Include that file at the archive root. Builds without Git metadata read
+`.version` and fail if it is missing or does not contain a nonempty single-line
+version. The Cargo manifest version is dependency metadata, not the version
+reported by the program.
+
Once Cargo is installed, we can install `robocop` with the command:
```
diff --git a/build-system/test_version.py b/build-system/test_version.py
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+
+# This file is part of Robocop
+#
+# Robocop is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Robocop 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+# Copyright (C) 2026 Taler Systems SA
+
+"""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(".")
+
+
+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/build.rs b/build.rs
@@ -0,0 +1,110 @@
+// This file is part of Robocop
+//
+// Robocop is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Robocop 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+//
+// Copyright (C) 2026 Taler Systems SA
+
+use std::env;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::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!("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 watch(path: &Path) {
+ println!("cargo:rerun-if-changed={}", path.display());
+}
+
+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());
+ }
+
+ // 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
+ .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/src/main.rs b/src/main.rs
@@ -22,7 +22,7 @@ use std::fs;
use std::io::{self, BufRead, BufReader, Write};
use std::process;
-const VERSION: &str = "1.0.0";
+const VERSION: &str = env!("BUILD_VERSION");
fn print_version() {
println!("robocop {}", VERSION);