[Fix] Fix setuptools-scm version resolution for rc tags (#22165)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Baizhou Zhang <sobereddiezhang@gmail.com>
This commit is contained in:
Kangyan-Zhou
2026-04-05 16:55:32 -07:00
committed by GitHub
co-authored by Claude Opus 4.6 Baizhou Zhang
parent 30ba1f78b0
commit 93109cc89b
9 changed files with 179 additions and 16 deletions
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Resolve the correct version tag for setuptools-scm.
Called by setuptools-scm via git_describe_command in pyproject.toml.
Outputs either a bare tag (e.g., "v0.5.10") for exact-match commits,
or a `git describe --long` string (e.g., "v0.5.10-2-gabcdef0") for
untagged commits. Both formats are accepted by setuptools-scm.
This two-step approach avoids a strverscmp bug where
`git tag --sort=-version:refname` sorts v0.5.10rc0 above v0.5.10,
which would cause CI to build the wrong version.
Strategy:
1. If the current commit has an exact version tag, use it directly.
This handles CI release builds (both stable and rc).
2. Otherwise, find the highest version tag across all branches
and describe relative to it. This handles local dev installs
from main where release tags only exist on release branches.
"""
import re
import subprocess
import sys
def parse_version_tuple(tag: str) -> tuple:
"""Parse a version tag into a sortable tuple using PEP 440 ordering.
Returns a tuple where:
- Base version parts are integers: (major, minor, patch)
- Pre-release suffix gets a lower sort key than bare version:
v0.5.10rc0 -> (0, 5, 10, 0, 0) # pre-release
v0.5.10 -> (0, 5, 10, 1, 0) # stable (sorts higher)
v0.5.10post1 -> (0, 5, 10, 2, 1) # post-release (sorts highest)
"""
v = tag.lstrip("v")
# Split base version from suffix
m = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:(rc|post)(\d+))?$", v)
if not m:
return (0, 0, 0, 0, 0)
major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3))
suffix_type = m.group(4)
suffix_num = int(m.group(5)) if m.group(5) else 0
if suffix_type == "rc":
return (major, minor, patch, 0, suffix_num)
elif suffix_type == "post":
return (major, minor, patch, 2, suffix_num)
else:
return (major, minor, patch, 1, 0)
def run_git(*args: str, allow_failure: bool = False) -> str:
"""Run a git command and return stripped stdout.
Args:
allow_failure: If True, return "" on non-zero exit (expected for
commands like --exact-match that legitimately fail).
If False, log stderr on failure before returning "".
"""
try:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
)
except OSError as exc:
print(f"ERROR: Failed to run 'git {' '.join(args)}': {exc}", file=sys.stderr)
sys.exit(1)
if result.returncode != 0:
if not allow_failure:
stderr_msg = result.stderr.strip()
print(
f"WARNING: git {' '.join(args)} failed "
f"(exit {result.returncode}): {stderr_msg}",
file=sys.stderr,
)
return ""
return result.stdout.strip()
def get_exact_version_tag() -> str:
"""Return the version tag name if HEAD has an exact version tag, or empty string."""
return run_git(
"describe", "--tags", "--exact-match", "--match", "v*", allow_failure=True
)
def get_latest_version_tag_describe() -> str:
"""Find the highest version tag and build a describe string relative to it.
Uses PEP 440 version ordering so that stable releases sort above
pre-release tags (e.g., v0.5.10 > v0.5.10rc0).
The highest tag may live on a release branch and not be a direct
ancestor of HEAD (e.g., main diverged before the release tag was
created). In that case, we compute the commit distance from the
merge-base and build the describe string manually.
"""
tag = get_latest_version_tag()
if not tag:
print("WARNING: No version tags (v*.*.*) found in repo", file=sys.stderr)
return ""
# Fast path: tag is an ancestor of HEAD, git describe works directly
result = run_git(
"describe", "--tags", "--long", "--match", tag, "HEAD", allow_failure=True
)
if result:
return result
# Tag is not an ancestor (e.g., release branch diverged from main).
# Build describe string manually: {tag}-{distance}-g{hash}
merge_base = run_git("merge-base", tag, "HEAD", allow_failure=True)
if not merge_base:
print(
f"WARNING: No common ancestor between {tag} and HEAD. "
f"Is this a shallow clone? Try: git fetch --unshallow --tags",
file=sys.stderr,
)
return ""
distance = run_git("rev-list", "--count", f"{merge_base}..HEAD")
short_hash = run_git("rev-parse", "--short", "HEAD")
return f"{tag}-{distance}-g{short_hash}"
def get_version_describe() -> str:
"""Main entry point: resolve the version describe string."""
# Prefer exact match — correct for both stable and pre-release tags
exact = get_exact_version_tag()
if exact:
return exact
# Fallback for untagged commits (e.g., dev install from main)
return get_latest_version_tag_describe()
def get_latest_version_tag() -> str:
"""Return just the highest version tag (PEP 440 ordered), or empty string."""
tags_raw = run_git("tag", "--list", "v*.*.*")
if not tags_raw:
return ""
tag_list = sorted(tags_raw.splitlines(), key=parse_version_tuple, reverse=True)
return tag_list[0] if tag_list else ""
def main() -> None:
# --tag-only: print just the latest version tag (for CI scripts)
tag_only = "--tag-only" in sys.argv
if tag_only:
result = get_latest_version_tag()
else:
result = get_version_describe()
if not result:
print(
"ERROR: Could not determine version from git tags.\n"
"Possible causes:\n"
" - No version tags (v*.*.*) exist: run 'git fetch --tags'\n"
" - Shallow clone without tags: run 'git fetch --unshallow --tags'\n"
" - Git safe.directory issue: run 'git config --global --add safe.directory <repo>'\n"
" - Not inside a git repository\n"
"setuptools-scm will fall back to version 0.0.0.dev0",
file=sys.stderr,
)
sys.exit(1)
print(result)
if __name__ == "__main__":
main()