libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit bd1e814cd34a17b2c0b4342fff077d45ed76aad9
parent 3a306f41e2f14fa4e65b87817ce3aeef990be7cc
Author: Florian Dold <dold@taler.net>
Date:   Sun,  6 Sep 2026 18:16:08 +0200

libeufin: derive program version from Git

Derive the program and Gradle project versions from Git, falling back
to .version in source archives. Track the resolved version as an input
to generated Kotlin constants and stamp it into source archives.

Remove the obsolete version resource and the release helper's update
of the Gradle version literal.

Diffstat:
M.gitignore | 5++---
MMakefile | 11++---------
MREADME | 7+++++++
Mbuild-system/archive.py | 15++++++++++++++-
Abuild-system/test_version.py | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abuild-system/version.gradle | 42++++++++++++++++++++++++++++++++++++++++++
Mbuild.gradle | 12+++---------
Mcontrib/bump-version | 17++---------------
Mlibeufin-common/build.gradle | 12+++++++-----
Dlibeufin-common/src/main/resources/version.txt | 2--
10 files changed, 299 insertions(+), 44 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,4 +1,5 @@ .idea/* +/.version .vscode libeufin-nexus/test common/tmp @@ -23,7 +24,6 @@ __pycache__ *.log .DS_Store *.mk -common/src/main/resources/version.txt debian/libeufin-bank debian/libeufin-common debian/libeufin-nexus @@ -31,4 +31,4 @@ debian/libeufin-ebisync debian/files debian/*.substvars debian/*debhelper* -azurite -\ No newline at end of file +azurite diff --git a/Makefile b/Makefile @@ -7,13 +7,6 @@ all: build archive = ./build-system/archive.py -git_tag=$(shell git describe --tags) -gradle_version=$(shell ./gradlew -q libeufinVersion) - -define versions_check = - if test $(git_tag) != $(gradle_version); \ - then echo WARNING: Project version from Gradle: $(gradle_version) differs from current Git tag: $(git_tag); fi -endef # Absolute DESTDIR or empty string if DESTDIR unset/empty abs_destdir=$(abspath $(DESTDIR)) @@ -33,9 +26,9 @@ build: .PHONY: dist dist: - $(call versions_check) mkdir -p build/distributions - $(archive) --include ./configure build/distributions/libeufin-$(gradle_version)-sources.tar.gz + version="$$(./gradlew -q libeufinVersion)" && \ + $(archive) --include ./configure "build/distributions/libeufin-$$version-sources.tar.gz" .PHONY: deb deb: diff --git a/README b/README @@ -51,6 +51,13 @@ $ make dist The TGZ file should be found at: build/distributions/libeufin-$VERSION-sources.tar.gz +The program version comes from `git describe --tags --always --abbrev=8` at +build time. The source archive includes this value in a root `.version` file, +which Gradle uses when Git metadata is unavailable. Git takes precedence over +any existing `.version` in a checkout. A source tree without either Git metadata +or a nonempty single-line `.version` cannot be built. No build fetches Git tags +or history; an untagged shallow checkout reports its abbreviated commit hash. + Exporting an archive with the three executables =============================================== diff --git a/build-system/archive.py b/build-system/archive.py @@ -7,11 +7,12 @@ from __future__ import annotations import argparse +import io import os -from pathlib import Path import subprocess import sys import tarfile +from pathlib import Path def run_git(repo: Path, *args: str, input_data: bytes | None = None) -> bytes: @@ -127,6 +128,10 @@ def main() -> int: output = args.output.resolve() prefix = archive_prefix(output) entries = repository_entries(root) + version = run_git(root, "describe", "--tags", "--always", "--abbrev=8").strip() + if not version or any(character in version for character in (b"\n", b"\r", b"\0")): + raise ValueError("program version must be a nonempty single line") + version += b"\n" included: list[tuple[Path, Path]] = [] for name in args.include: @@ -141,7 +146,15 @@ def main() -> int: included.append((source, relative)) with tarfile.open(output, "w:gz") as archive: + stamp = tarfile.TarInfo(str(prefix / ".version")) + stamp.size = len(version) + stamp.mode = 0o644 + stamp.mtime = int(run_git(root, "log", "-1", "--format=%ct")) + archive.addfile(stamp, io.BytesIO(version)) for source, relative in [*included, *entries]: + # A stale stamp in the checkout must not override the Git version. + if relative == Path(".version"): + continue archive.add(source, arcname=prefix / relative, recursive=False) print(f"created {output}") diff --git a/build-system/test_version.py b/build-system/test_version.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 + +# This file is in the public domain. + +"""Test Git/archive versioning and Gradle task invalidation without compiling Kotlin.""" + +import os +import re +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +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", + "JAVA_TOOL_OPTIONS": "-Djava.io.tmpdir=" + tempfile.gettempdir(), + } + ) + for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"): + self.env.pop(name, None) + configs = subprocess.check_output( + ["git", "ls-files", "*.gradle", "gradle.properties"], cwd=ROOT, text=True + ).splitlines() + configs.append("build-system/version.gradle") + for name in configs: + target = self.repo / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / name, target) + (self.repo / ".gitignore").write_text("build\n.gradle\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( + str(ROOT / "gradlew"), + "--offline", + "--console=plain", + "--project-dir", + str(repo or self.repo), + "libeufinVersion", + ":libeufin-common:versionConstant", + cwd=repo, + check=check, + ) + + def assert_version(self, expected, repo=None): + result = self.build(repo) + generated = ( + repo or self.repo + ) / "libeufin-common/build/generated/constants/CompileConstants.kt" + actual = re.search( + r'val VERSION: String = "(.*)"', generated.read_text() + ).group(1) + self.assertEqual(expected, actual) + self.assertIn(expected, result.stdout.splitlines()) + 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(":libeufin-common:versionConstant UP-TO-DATE", unchanged.stdout) + 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", 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 + ) + + def test_source_archive_stamps_git_version(self): + self.initialize() + (self.repo / ".version").write_text("v0.0.1\n") + self.git("add", "-f", ".version") + self.git("commit", "-m", "Stale version stamp") + self.git("tag", "v2.0.0") + archive = self.base / "source.tar.gz" + self.command("python3", str(ROOT / "build-system/archive.py"), str(archive)) + with tarfile.open(archive) as contents: + self.assertEqual(1, contents.getnames().count("source/.version")) + self.assertEqual( + b"v2.0.0\n", contents.extractfile("source/.version").read() + ) + self.assertFalse(any("/.git/" in name for name in contents.getnames())) + unpacked = self.base / "unpacked" + contents.extractall(unpacked, filter="data") + self.assert_version("v2.0.0", unpacked / "source") + + +if __name__ == "__main__": + unittest.main() diff --git a/build-system/version.gradle b/build-system/version.gradle @@ -0,0 +1,42 @@ +// This file is in the public domain. + +// Applied by the root project, independently of the Kotlin build plugins. +def git = { List<String> arguments -> + def result = providers.exec { + workingDir rootDir + commandLine(['git'] + arguments) + environment = System.getenv().findAll { name, value -> + !(name in ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']) + } + ignoreExitValue = true + } + if (result.result.get().exitValue != 0) { + throw new GradleException("git ${arguments.join(' ')}: ${result.standardError.asText.get().trim()}") + } + result.standardOutput.asText.get().trim() +} + +String resolvedVersion +try { + // Do not describe an enclosing repository when building a source archive. + if (!new File(rootDir, '.git').exists()) { + throw new GradleException('source tree has no Git metadata') + } + if (new File(git(['rev-parse', '--show-toplevel'])).canonicalFile != rootDir.canonicalFile) { + throw new GradleException('Git metadata belongs to another source tree') + } + resolvedVersion = git(['describe', '--tags', '--always', '--abbrev=8']) +} catch (Exception gitError) { + def stamp = new File(rootDir, '.version') + if (!stamp.isFile()) { + throw new GradleException( + "Cannot determine program version: ${gitError.message}. Source archives must contain ${stamp}", + gitError + ) + } + resolvedVersion = stamp.getText('UTF-8').trim() +} +if (!resolvedVersion || resolvedVersion.contains('\n') || resolvedVersion.contains('\r') || resolvedVersion.contains('\u0000')) { + throw new GradleException('Program version must be a nonempty single line') +} +version = resolvedVersion diff --git a/build.gradle b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = "tech.libeufin" -version = "1.6.8" +apply from: 'build-system/version.gradle' if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)){ throw new GradleException( @@ -67,14 +67,9 @@ subprojects { } } -ext.getVersionWithGitHash = { -> - def gitHash = 'git rev-parse --short HEAD'.execute().text.trim() - return "v${project.version}-git-$gitHash" -} - task libeufinVersion { doLast { - println getVersionWithGitHash() + println project.version } } @@ -84,4 +79,4 @@ dependencies { dokka(project(":libeufin-nexus:")) dokka(project(":libeufin-ebics:")) dokka(project(":libeufin-ebisync:")) -} -\ No newline at end of file +} diff --git a/contrib/bump-version b/contrib/bump-version @@ -58,18 +58,5 @@ if not dry and deb_current_version != version: with open("debian/changelog", "w") as f: f.write(new_changelog) -# Bump version in build.gradle - -with open("build.gradle") as f: - contents = f.read() -gradle_pat = r'version.*=.*"(.*)"' -m = re.search(gradle_pat, contents) -gradle_current_version = m.group(1) - -new_contents = re.sub(gradle_pat, f'version = "{new_version}"', contents) -gradle_bump = " [!]" if gradle_current_version != version else "" -print(f"build.gradle: {gradle_current_version} -> {version}{gradle_bump}") - -if not dry: - with open("build.gradle", "w") as f: - f.write(new_contents) +# The program version is derived from Git tags at build time; there is no +# Gradle version literal to bump. diff --git a/libeufin-common/build.gradle b/libeufin-common/build.gradle @@ -16,7 +16,9 @@ compileTestKotlin.kotlinOptions.jvmTarget = "17" task versionConstant { def outputDir = file("$buildDir/generated/constants") def outputFile = new File(outputDir, "CompileConstants.kt") + def programVersion = rootProject.version.toString() + inputs.property 'programVersion', programVersion outputs.dir outputDir doLast { @@ -24,11 +26,12 @@ task versionConstant { outputDir.mkdirs() // Generate the Kotlin constants file - outputFile.text = """ + def versionLiteral = groovy.json.JsonOutput.toJson(programVersion).replace('$', '\\$') + outputFile.setText(""" package tech.libeufin.common - val VERSION: String = "${getVersionWithGitHash()}" - """.stripIndent() + val VERSION: String = ${versionLiteral} + """.stripIndent(), 'UTF-8') } } @@ -78,4 +81,4 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") testImplementation("uk.org.webcompere:system-stubs-core:2.1.8") -} -\ No newline at end of file +} diff --git a/libeufin-common/src/main/resources/version.txt b/libeufin-common/src/main/resources/version.txt @@ -1 +0,0 @@ -v1.0.6-git-942f58a3 -\ No newline at end of file