From dfeb03b8bb764230e2fedcd4cb65b88d9919988b Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 22 May 2026 00:39:59 +0100 Subject: [PATCH] fix(semver): skip non-semver tags when picking latest existing tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling tags like 'v1' (and any other tag that doesn't parse as x.y.z after prefix stripping) used to enter the latest-tag candidate set in CalculateSemver. When such a tag shared a commit with a real semver tag (e.g. v1 and v1.16.3 both pointing at the same release commit), go-git's alphabetical tag iteration made 'v1' win, ParseExistingSemver bailed out because '1' has only one component, and the calculator silently reset the baseline to 0.0.0 — producing nonsense like 0.0.5 on this branch. ListExistingTags now filters tags through a new IsParseableSemverTag helper before recording them, so non-semver tags never participate in latest-tag selection. The behavior change is invisible for repos that only use proper vX.Y.Z tags, and it's covered by a new test table. --- cmd/main.go | 2 +- cmd/utils/git.go | 17 +++++++++++------ cmd/utils/git_test.go | 2 +- cmd/utils/version.go | 23 +++++++++++++++++++++++ cmd/utils/version_test.go | 28 ++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index a6db7a0..ead2479 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -129,7 +129,7 @@ func main() { // List existing tags if needed if params.varExisting || repo.Config.Force.Existing { - utils.ListExistingTags(&repo.GitRepo) + utils.ListExistingTags(&repo.GitRepo, repo.Config.TagPrefixes) } // Apply forced versioning diff --git a/cmd/utils/git.go b/cmd/utils/git.go index 190dd4d..d3763c8 100644 --- a/cmd/utils/git.go +++ b/cmd/utils/git.go @@ -163,10 +163,13 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) { } // ListExistingTags lists all tags in the repository -func ListExistingTags(repo *GitRepository) { +// ListExistingTags lists all tags in the repository. +// Tags that don't parse as proper semver (rolling tags like "v1" or "latest") +// are skipped so they can't out-rank real semver tags pointing to the same +// commit during latest-tag selection. +func ListExistingTags(repo *GitRepository, tagPrefixes []string) { Debug("Listing existing tags", nil) - // Check if Handler is nil to avoid panic if repo.Handler == nil { Debug("Repository handler is nil, skipping tag listing", nil) return @@ -180,15 +183,17 @@ func ListExistingTags(repo *GitRepository) { if err := refs.ForEach(func(ref *plumbing.Reference) error { tagName := ref.Name().Short() - commitHash := ref.Hash().String() - // For annotated tags, dereference to get the actual commit hash + if !IsParseableSemverTag(tagName, tagPrefixes) { + Debug("Skipping non-semver tag", map[string]interface{}{"tag": tagName}) + return nil + } + + commitHash := ref.Hash().String() tagObj, err := repo.Handler.TagObject(ref.Hash()) if err == nil { - // This is an annotated tag - get the commit it points to commitHash = tagObj.Target.String() } - // If err != nil, it's a lightweight tag - ref.Hash() is already the commit hash repo.Tags = append(repo.Tags, TagDetails{ Name: tagName, diff --git a/cmd/utils/git_test.go b/cmd/utils/git_test.go index 15f1dbd..b5937a0 100644 --- a/cmd/utils/git_test.go +++ b/cmd/utils/git_test.go @@ -142,7 +142,7 @@ func TestListExistingTags(t *testing.T) { repo := &GitRepository{} // Now we can safely call ListExistingTags since we've added a nil check - ListExistingTags(repo) + ListExistingTags(repo, nil) // Verify no tags were added assert.Empty(t, repo.Tags, "Should have no tags after calling with nil Handler") diff --git a/cmd/utils/version.go b/cmd/utils/version.go index 6b9b03b..001349f 100644 --- a/cmd/utils/version.go +++ b/cmd/utils/version.go @@ -83,6 +83,29 @@ func StripTagPrefix(tagName string, prefixes []string) string { return result } +// IsParseableSemverTag reports whether tagName looks like a proper semver tag +// (X.Y.Z, optionally vX.Y.Z, optionally with -rc.N suffix) after configured +// prefixes are stripped. Rolling tags like "v1" or "latest" return false so the +// calculator can skip them when picking the latest existing tag — preventing a +// rolling tag tied to the same commit as a real semver tag from "winning" the +// alphabetical iteration in go-git and resetting the baseline to 0.0.0. +func IsParseableSemverTag(tagName string, prefixes []string) bool { + clean := StripTagPrefix(tagName, prefixes) + if idx := strings.Index(clean, "-rc."); idx != -1 { + clean = clean[:idx] + } + parts := strings.Split(clean, ".") + if len(parts) < 3 { + return false + } + for _, p := range parts[:3] { + if len(extractNumber.FindAllString(p, -1)) == 0 { + return false + } + } + return true +} + // ParseExistingSemver parses a semantic version from a tag name func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string) SemVer { Debug("Parsing existing semver", map[string]interface{}{"tag": tagName}) diff --git a/cmd/utils/version_test.go b/cmd/utils/version_test.go index 414bea0..e429f4b 100644 --- a/cmd/utils/version_test.go +++ b/cmd/utils/version_test.go @@ -189,6 +189,34 @@ func TestParseExistingSemver(t *testing.T) { }) } } +func TestIsParseableSemverTag(t *testing.T) { + InitLogger(false) + + tests := []struct { + name string + tag string + prefixes []string + want bool + }{ + {name: "plain x.y.z", tag: "1.2.3", want: true}, + {name: "v prefix", tag: "v1.16.5", want: true}, + {name: "v prefix with rc", tag: "v2.0.0-rc.3", want: true}, + {name: "configured app- prefix", tag: "app-1.2.3", prefixes: []string{"app-"}, want: true}, + + {name: "rolling v1 tag", tag: "v1", want: false}, + {name: "rolling latest tag", tag: "latest", want: false}, + {name: "two-component", tag: "1.2", want: false}, + {name: "empty", tag: "", want: false}, + {name: "non-numeric major", tag: "vX.Y.Z", want: false}, + {name: "just text", tag: "release-day", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsParseableSemverTag(tt.tag, tt.prefixes)) + }) + } +} func TestCheckMatches(t *testing.T) { // Initialize logger for tests