From 3374572f8178e2282b9a92778392f96ee5793f70 Mon Sep 17 00:00:00 2001 From: lxcxjxhx Date: Tue, 29 Sep 2026 20:52:48 +0800 Subject: [PATCH 1/2] Add prefer_tag option to use the latest tag over the version file When 'version_file' is set, tags in the repo are ignored. This is a footgun for release flows that push a tag like 'v2.1.0rc1' while the version file still says '2.1.0': the built package silently gets version 2.1.0 (see issue #155). Add a 'prefer_tag: bool' option (default False to keep the current behavior). When enabled and the repo has at least one tag, the latest tag is used as the version source instead of the version file content, honoring 'tag_formatter', dev/dirty templates and 'ccount' exactly like the tag-based schema does. When the tag and the version file content differ (ignoring a leading 'v'), a warning is logged either way, so stale version files are easier to notice. --- docs/options/index.rst | 1 + docs/options/prefer_tag.rst | 28 +++++++ docs/schemas/file/version_file.rst | 8 ++ setuptools_git_versioning/defaults.py | 1 + setuptools_git_versioning/version.py | 71 +++++++++++----- tests/test_integration/test_version_file.py | 89 +++++++++++++++++++++ 6 files changed, 178 insertions(+), 20 deletions(-) create mode 100644 docs/options/prefer_tag.rst diff --git a/docs/options/index.rst b/docs/options/index.rst index 009116c..79516c0 100644 --- a/docs/options/index.rst +++ b/docs/options/index.rst @@ -13,6 +13,7 @@ Config options dirty_template version_file count_commits + prefer_tag version_callback sort_by branch_formatter diff --git a/docs/options/prefer_tag.rst b/docs/options/prefer_tag.rst new file mode 100644 index 0000000..3cd5d34 --- /dev/null +++ b/docs/options/prefer_tag.rst @@ -0,0 +1,28 @@ +.. _prefer-tag-option: + +``prefer_tag`` +~~~~~~~~~~~~~~ + +Used together with the :ref:`version-file-option` option. + +By default, when :ref:`version-file-option` is set, any tags in the repo are +ignored (see :issue:`155` in the issue tracker of this project for the +discussion). With this option enabled, the latest Git tag takes precedence +over the version file content. + +.. note:: + + This option is used only with :ref:`version-file-option`, + and only takes effect when the repo actually contains at least one tag; + otherwise the version file content is used as usual. + +When the tag and the version file content are different, +a warning is logged, so stale version files are easier to notice. + +Type +^^^^ +``bool`` + +Default value +^^^^^^^^^^^^^ +``False`` diff --git a/docs/schemas/file/version_file.rst b/docs/schemas/file/version_file.rst index 1a56a0d..6001424 100644 --- a/docs/schemas/file/version_file.rst +++ b/docs/schemas/file/version_file.rst @@ -66,8 +66,16 @@ of this file (``1.0.0``) will be returned instead default version number. **Please take into account that any tags in the repo are ignored if this option is being used.** +.. note:: + + If you want the latest Git tag to take precedence over the version file + (for example, when a release tag like ``v2.1.0rc1`` is pushed but the + version file still contains ``2.1.0``), set the + :ref:`prefer-tag-option` option to ``true``. + See also """"""""" - :ref:`version-callback` - :ref:`version-file-option` option +- :ref:`prefer-tag-option` option - :ref:`runtime-version` diff --git a/setuptools_git_versioning/defaults.py b/setuptools_git_versioning/defaults.py index 139e401..51c7b4f 100644 --- a/setuptools_git_versioning/defaults.py +++ b/setuptools_git_versioning/defaults.py @@ -13,6 +13,7 @@ "version_callback": None, "version_file": None, "count_commits_from_version_file": False, + "prefer_tag": False, "tag_formatter": None, "branch_formatter": None, "tag_filter": None, diff --git a/setuptools_git_versioning/version.py b/setuptools_git_versioning/version.py index 01efa16..1c74114 100644 --- a/setuptools_git_versioning/version.py +++ b/setuptools_git_versioning/version.py @@ -96,6 +96,7 @@ def version_from_git( # noqa: PLR0915, PLR0912, PLR0913, C901 version_callback: str | Callable[[], str] | None = None, version_file: str | os.PathLike | None = None, count_commits_from_version_file: bool = False, + prefer_tag: bool = False, tag_formatter: Callable[[str], str] | str | None = None, branch_formatter: Callable[[str], str] | str | None = None, tag_filter: Callable[[str], str | None] | str | None = None, @@ -156,33 +157,63 @@ def version_from_git( # noqa: PLR0915, PLR0912, PLR0913, C901 log.log(INFO, "HEAD is tagged: %r", on_tag) if version_file: - log.log(INFO, "Checking for 'version_file'") - - version_file_path = project_root.joinpath(version_file) - if not version_file_path.exists(): + if prefer_tag and tag is not None: log.log( INFO, - "version_file '%s' does not exist, return starting_version %r", - version_file_path, - starting_version, + "Both 'version_file' and a Git tag (%r) are present, and 'prefer_tag' is enabled: using the tag", + tag, ) - tag = None + if tag_sha is None: + tag_sha = get_sha(tag, root=root) + log.log(DEBUG, "Tag SHA-256: %r", tag_sha) + on_tag = head_sha is not None and tag_sha is not None and head_sha == tag_sha + file_tag = None + version_file_path = project_root.joinpath(version_file) + if version_file_path.exists(): + file_tag = version_file_path.read_text().strip() or None + if file_tag and file_tag.lstrip("v") != tag.lstrip("v"): + log.warning( + "Git tag %r and 'version_file' content %r are different: using the tag. " + "Update the version file to silence this warning", + tag, + file_tag, + ) + ccount = count_since(tag_sha, root=root) if tag_sha else None + log.log(INFO, "Commits count between HEAD and last tag: %r", ccount) + + if tag_formatter is not None: + tag_format_callback = create_tag_formatter(tag_formatter, package_name=package_name, root=root) + tag = tag_format_callback(tag) + log.log(DEBUG, "Tag after formatting: %r", tag) else: - log.log(INFO, "Reading version_file '%s' content", version_file) - tag = version_file_path.read_text().strip() or None - - if not tag: - log.log(INFO, "File %r is empty", version_file) + log.log(INFO, "Checking for 'version_file'") + + version_file_path = project_root.joinpath(version_file) + if not version_file_path.exists(): + log.log( + INFO, + "version_file '%s' does not exist, return starting_version %r", + version_file_path, + starting_version, + ) + tag = None else: - log.log(DEBUG, "File content: %r", tag) - if not count_commits_from_version_file: - return sanitize_version(tag) + log.log(INFO, "Reading version_file '%s' content", version_file) + tag = version_file_path.read_text().strip() or None + + if not tag: + log.log(INFO, "File %r is empty", version_file) + else: + log.log(DEBUG, "File content: %r", tag) + + if not count_commits_from_version_file: + return sanitize_version(tag) - file_sha = get_latest_file_commit(version_file, root=root) - log.log(DEBUG, "File SHA-256: %r", file_sha) + file_sha = get_latest_file_commit(version_file, root=root) + log.log(DEBUG, "File SHA-256: %r", file_sha) - ccount = count_since(file_sha, root=root) if file_sha else None - log.log(INFO, "Commits count between HEAD and last version file change: %r", ccount) + ccount = count_since(file_sha, root=root) if file_sha else None + log.log(INFO, "Commits count between HEAD and last version file change: %r", ccount) elif not head_sha: log.log(INFO, "Not a git repo, or repo without any branch") diff --git a/tests/test_integration/test_version_file.py b/tests/test_integration/test_version_file.py index dfac57a..5479c49 100644 --- a/tests/test_integration/test_version_file.py +++ b/tests/test_integration/test_version_file.py @@ -312,3 +312,92 @@ def test_version_file_git_not_executable(repo, create_config, count_commits, tmp tmp_path = tmp_path_factory.mktemp("bin") tmp_path.joinpath("git").touch() assert get_version(repo, env={"PATH": os.fspath(tmp_path)}) == "1.0.0" + + +@pytest.mark.flaky(reruns=3) # sha and full_sha can start with 0 which are removed, just try again +def test_version_file_prefer_tag_off_by_default(repo, create_config): + # without prefer_tag, the version file wins even when a tag is present + create_file(repo, "VERSION.txt", "1.0.0") + create_config( + repo, + { + "version_file": "VERSION.txt", + "count_commits_from_version_file": True, + }, + ) + create_tag(repo, "1.2.3") + + assert get_version(repo) == "1.0.0" + + +@pytest.mark.flaky(reruns=3) # sha and full_sha can start with 0 which are removed, just try again +@pytest.mark.parametrize("count_commits_from_version_file", [True, False]) +def test_version_file_prefer_tag_on_tagged_head(repo, create_config, count_commits_from_version_file): + # prefer_tag makes the tag win over the version file content on a tagged HEAD + create_file(repo, "VERSION.txt", "1.0.0") + create_config( + repo, + { + "version_file": "VERSION.txt", + "count_commits_from_version_file": count_commits_from_version_file, + "prefer_tag": True, + }, + ) + create_tag(repo, "1.2.3") + + assert get_version(repo) == "1.2.3" + + +@pytest.mark.flaky(reruns=3) # sha and full_sha can start with 0 which are removed, just try again +def test_version_file_prefer_tag_dev_commits(repo, create_config): + # prefer_tag + commits after the tag: tag is used as the version source, + # dev template kicks in because HEAD is not on the tag + create_file(repo, "VERSION.txt", "1.0.0") + create_config( + repo, + { + "version_file": "VERSION.txt", + "count_commits_from_version_file": True, + "prefer_tag": True, + }, + ) + create_tag(repo, "1.2.3") + # create_file with default add=True/commit=True creates the commit after the tag + create_file(repo, "work.txt", "work after tag") + + sha = get_sha(repo) + assert get_version(repo) == f"1.2.3.post1+git.{sha}" + + +@pytest.mark.flaky(reruns=3) # sha and full_sha can start with 0 which are removed, just try again +def test_version_file_prefer_tag_without_any_tag(repo, create_config): + # prefer_tag with no tags in the repo: version file is still the source + # (count_commits_from_version_file is off here so the file content is + # returned as-is, without the dev suffix) + create_file(repo, "VERSION.txt", "1.0.0") + create_config( + repo, + { + "version_file": "VERSION.txt", + "prefer_tag": True, + }, + ) + + assert get_version(repo) == "1.0.0" + + +@pytest.mark.flaky(reruns=3) # sha and full_sha can start with 0 which are removed, just try again +def test_version_file_prefer_tag_v_prefix(repo, create_config): + # tag with a "v" prefix is compared to the version file without the prefix + create_file(repo, "VERSION.txt", "1.2.3") + create_config( + repo, + { + "version_file": "VERSION.txt", + "count_commits_from_version_file": True, + "prefer_tag": True, + }, + ) + create_tag(repo, "v1.2.3") + + assert get_version(repo) == "1.2.3" From d91f0750996ae06b41466939e2a63062aba4e0fc Mon Sep 17 00:00:00 2001 From: Hyacinth-of-Security <144213008+lxcxjxhx@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:45:43 +0800 Subject: [PATCH 2/2] Update docs/options/prefer_tag.rst Co-authored-by: Maxim Martynov --- docs/options/prefer_tag.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/options/prefer_tag.rst b/docs/options/prefer_tag.rst index 3cd5d34..93d4a88 100644 --- a/docs/options/prefer_tag.rst +++ b/docs/options/prefer_tag.rst @@ -6,9 +6,7 @@ Used together with the :ref:`version-file-option` option. By default, when :ref:`version-file-option` is set, any tags in the repo are -ignored (see :issue:`155` in the issue tracker of this project for the -discussion). With this option enabled, the latest Git tag takes precedence -over the version file content. +ignored (see :issue:`155`). With this option enabled, the latest Git tag takes precedence over the version file content. .. note::