robocop

Checks KYC attributes against sanction lists
Log | Files | Refs | Submodules | README | LICENSE

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:
M.gitignore | 1+
MREADME.md | 13+++++++++++++
Abuild-system/test_version.py | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abuild.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 2+-
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);