From 3a48a67c7568bbbf2a723c3e882a0e4288679a20 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Wed, 10 Dec 2025 14:37:38 +0000 Subject: [PATCH] Improve calculation logic, add ability to strip prefixes. --- README.md | 37 +++++++++++++++ cmd/main.go | 7 ++- cmd/main_test.go | 57 ++++++++++++++++-------- cmd/utils/config.go | 34 +++++++++----- cmd/utils/git.go | 72 +++++++++++++++++------------- cmd/utils/github.go | 30 +++++++++---- cmd/utils/semver.go | 25 ++++++----- cmd/utils/semver_test.go | 63 +++++++++++++++++++++----- cmd/utils/version.go | 94 ++++++++++++++++++++++++++++++--------- cmd/utils/version_test.go | 75 ++++++++++++++++++++++++++----- config-release.yaml | 5 +++ config.yaml | 4 ++ 12 files changed, 376 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index fcb7bc1..2406385 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Project created overnight, to prove that management of semantic versioning is NO - [Calculations example \[standard\]](#calculations-example-standard) - [Calculations example \[strict matching\]](#calculations-example-strict-matching) - [Release candidates](#release-candidates) + - [Tag prefix stripping](#tag-prefix-stripping) - [Example configuration](#example-configuration) - [Good to know](#good-to-know) @@ -29,6 +30,7 @@ Project created overnight, to prove that management of semantic versioning is NO * With flag `-e` or config `force.existing: true` the existing tags in versioning will be respected, helping you to avoid the version conflicts. * With config `force.commit: deadbeef` where `deadbeef` is the commit hash - calculations will start from the specified commit. +* Tag prefix stripping: The `v` prefix is automatically stripped from tags (e.g., `v1.2.3` → `1.2.3`). Additional prefixes can be configured via `tag_prefixes` for monorepo setups (e.g., `app-1.2.3`, `infra-1.2.3`). ### Important changes @@ -182,6 +184,36 @@ to generate the appropriate release in format `1.3.37-rc.1` and counting up unti - add-rc ``` +#### Tag prefix stripping + +When using the `-e` (existing tags) flag, the semver-generator needs to parse existing git tags to determine the current version. Tags often include prefixes that need to be stripped before version parsing. + +**Automatic `v` prefix stripping:** +The `v` prefix is always stripped automatically from tags. For example: +- `v1.2.3` → parsed as `1.2.3` +- `v0.5.0` → parsed as `0.5.0` + +**Custom prefixes for monorepos:** +In monorepo setups where different components have their own versioned tags, you can configure additional prefixes to strip: + +```yaml +tag_prefixes: + - "app-" + - "infra-" + - "api-" + - "frontend-" +``` + +With this configuration: +- `app-1.2.3` → parsed as `1.2.3` +- `infra-0.5.0` → parsed as `0.5.0` +- `api-2.0.0-rc.1` → parsed as `2.0.0-rc.1` (release candidate) + +This is particularly useful when: +- You have multiple services/components in a single repository +- Your CI/CD creates tags with component prefixes +- You want to track versions separately for different parts of your codebase + #### Example configuration ```yaml @@ -196,6 +228,10 @@ blacklist: - "Merge pull request" - "feature/" - "feature:" +tag_prefixes: + - "app-" + - "infra-" + - "service-" wording: patch: - update @@ -215,6 +251,7 @@ wording: * `force`: sets the "starting" version, you don't need to specify this section as the default is always `0` * `force.commit`: allows you to set commit hash from which the calculations should start * `blacklist`: terms to ignore when processing commits. Any commit containing these terms will be skipped in version calculations. Useful for ignoring merge commits, feature branch names, and other unwanted triggers. +* `tag_prefixes`: prefixes to strip from existing tags before parsing version numbers. Useful for monorepos where tags are prefixed with component names (e.g., `app-1.2.3`, `infra-0.5.0`). The `v` prefix is always stripped automatically. * `wording`: words the program should look for in the git commits to increment (patch|minor|major) ### Good to knows diff --git a/cmd/main.go b/cmd/main.go index fa3fb73..a6db7a0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -121,7 +121,11 @@ func main() { } // List commits - utils.ListCommits(&repo.GitRepo) + if _, err := utils.ListCommits(&repo.GitRepo); err != nil { + utils.Error("Unable to list commits", map[string]interface{}{ + "error": err.Error(), + }) + } // List existing tags if needed if params.varExisting || repo.Config.Force.Existing { @@ -140,6 +144,7 @@ func main() { repo.Semver, params.varExisting || repo.Config.Force.Existing, params.varStrict || repo.Config.Force.Strict, + repo.Config.TagPrefixes, ) // Print semantic version diff --git a/cmd/main_test.go b/cmd/main_test.go index 4a80c77..0298714 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -267,7 +267,7 @@ func (suite *Tests) Test_checkMatches() { if tt.name == "No match" { return nil } - + // For other test cases, match if the needle is in the haystack for _, h := range haystack { if strings.Contains(h, needle) || strings.Contains(needle, h) { @@ -276,7 +276,7 @@ func (suite *Tests) Test_checkMatches() { } return nil } - + got := utils.CheckMatches(tt.args.content, tt.args.targets, tt.blacklist) assertObj.Equal(tt.want, got, "Unexpected result in "+tt.name) }) @@ -285,7 +285,8 @@ func (suite *Tests) Test_checkMatches() { func (suite *Tests) Test_parseExistingSemver() { type args struct { - tagName string + tagName string + prefixes []string } tests := []struct { name string @@ -296,7 +297,8 @@ func (suite *Tests) Test_parseExistingSemver() { { name: "Test parsing existing semver", args: args{ - tagName: "1.2.3", + tagName: "1.2.3", + prefixes: []string{}, }, currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1}, wantSemanticVersion: utils.SemVer{ @@ -308,7 +310,8 @@ func (suite *Tests) Test_parseExistingSemver() { { name: "Test parsing existing semver with v", args: args{ - tagName: "v1.2.3", + tagName: "v1.2.3", + prefixes: []string{"v"}, }, currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1}, wantSemanticVersion: utils.SemVer{ @@ -320,7 +323,8 @@ func (suite *Tests) Test_parseExistingSemver() { { name: "Test parsing existing semver with rc", args: args{ - tagName: "1.2.5-rc.7", + tagName: "1.2.5-rc.7", + prefixes: []string{}, }, currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1}, wantSemanticVersion: utils.SemVer{ @@ -331,10 +335,25 @@ func (suite *Tests) Test_parseExistingSemver() { EnableReleaseCandidate: true, }, }, + { + name: "Test parsing prefixed tag without rc", + args: args{ + tagName: "app-0.0.16", + prefixes: []string{"app-", "infra-"}, + }, + currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1}, + wantSemanticVersion: utils.SemVer{ + Major: 0, + Minor: 0, + Patch: 16, + EnableReleaseCandidate: false, + }, + }, { name: "Test invalid semver format", args: args{ - tagName: "invalid", + tagName: "invalid", + prefixes: []string{}, }, currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4}, wantSemanticVersion: utils.SemVer{ @@ -346,7 +365,8 @@ func (suite *Tests) Test_parseExistingSemver() { { name: "Test partial semver", args: args{ - tagName: "1.2", + tagName: "1.2", + prefixes: []string{}, }, currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4}, wantSemanticVersion: utils.SemVer{ @@ -358,7 +378,8 @@ func (suite *Tests) Test_parseExistingSemver() { { name: "Test empty tag", args: args{ - tagName: "", + tagName: "", + prefixes: []string{}, }, currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4}, wantSemanticVersion: utils.SemVer{ @@ -370,7 +391,7 @@ func (suite *Tests) Test_parseExistingSemver() { } for _, tt := range tests { suite.T().Run(tt.name, func(t *testing.T) { - got := utils.ParseExistingSemver(tt.args.tagName, tt.currentSemver) + got := utils.ParseExistingSemver(tt.args.tagName, tt.currentSemver, tt.args.prefixes) assertObj.Equal(tt.wantSemanticVersion.Major, got.Major, "Unexpected MAJOR semver result in "+tt.name) assertObj.Equal(tt.wantSemanticVersion.Minor, got.Minor, "Unexpected MINOR semver result in "+tt.name) assertObj.Equal(tt.wantSemanticVersion.Patch, got.Patch, "Unexpected PATCH semver result in "+tt.name) @@ -382,10 +403,10 @@ func (suite *Tests) Test_parseExistingSemver() { func (suite *Tests) TestSetup_ListCommits() { type fields struct { - RepositoryName string - RepositoryBranch string - LocalConfigFile string - GitRepo utils.GitRepository + RepositoryName string + RepositoryBranch string + LocalConfigFile string + GitRepo utils.GitRepository } tests := []struct { @@ -441,23 +462,23 @@ func (suite *Tests) TestSetup_ListCommits() { if tt.name == "List commits from existing repository" { t.Skip("Skipping test that requires repository access") } - + s := &Setup{ RepositoryName: tt.fields.RepositoryName, RepositoryBranch: tt.fields.RepositoryBranch, GitRepo: tt.fields.GitRepo, } - + config, _ := utils.ReadConfig(tt.fields.LocalConfigFile) s.Config = config - + err := utils.PrepareRepository(&s.GitRepo) if err != nil && !tt.wantErr { if tt.name != "List commits starting with certain hash" { t.Fatalf("Failed to prepare repository: %v", err) } } - + if err == nil { listOfCommits, err := utils.ListCommits(&s.GitRepo) if !tt.wantErr { diff --git a/cmd/utils/config.go b/cmd/utils/config.go index e46948b..3b1483f 100644 --- a/cmd/utils/config.go +++ b/cmd/utils/config.go @@ -26,26 +26,36 @@ type Force struct { // Config represents the application configuration type Config struct { - Wording Wording - Force Force - Blacklist []string + Wording Wording + Force Force + Blacklist []string + TagPrefixes []string // Prefixes to strip from tags before parsing (e.g., "app-", "infra-", "v") } // ReadConfig reads the configuration from a file func ReadConfig(file string) (*Config, error) { config := &Config{} - + viper.SetConfigFile(file) err := viper.ReadInConfig() if err != nil { err = fmt.Errorf("fatal error config file: %s", err) return config, err } - - viper.UnmarshalKey("wording", &config.Wording) - viper.UnmarshalKey("force", &config.Force) - viper.UnmarshalKey("blacklist", &config.Blacklist) - + + if err := viper.UnmarshalKey("wording", &config.Wording); err != nil { + return config, fmt.Errorf("error parsing wording config: %w", err) + } + if err := viper.UnmarshalKey("force", &config.Force); err != nil { + return config, fmt.Errorf("error parsing force config: %w", err) + } + if err := viper.UnmarshalKey("blacklist", &config.Blacklist); err != nil { + return config, fmt.Errorf("error parsing blacklist config: %w", err) + } + if err := viper.UnmarshalKey("tag_prefixes", &config.TagPrefixes); err != nil { + return config, fmt.Errorf("error parsing tag_prefixes config: %w", err) + } + return config, nil } @@ -55,14 +65,14 @@ func ApplyForcedVersioning(force Force, semver *SemVer) { Debug("Forced versioning (MAJOR)", map[string]interface{}{"major": force.Major}) semver.Major = force.Major } - + if force.Minor > 0 { Debug("Forced versioning (MINOR)", map[string]interface{}{"minor": force.Minor}) semver.Minor = force.Minor } - + if force.Patch > 0 { Debug("Forced versioning (PATCH)", map[string]interface{}{"patch": force.Patch}) semver.Patch = force.Patch } -} \ No newline at end of file +} diff --git a/cmd/utils/git.go b/cmd/utils/git.go index 8af6b24..db29f83 100644 --- a/cmd/utils/git.go +++ b/cmd/utils/git.go @@ -29,14 +29,14 @@ type TagDetails struct { // GitRepository represents a git repository type GitRepository struct { - Handler *git.Repository - Name string - Branch string - LocalPath string - UseLocal bool - Commits []CommitDetails - Tags []TagDetails - StartCommit string + Handler *git.Repository + Name string + Branch string + LocalPath string + UseLocal bool + Commits []CommitDetails + Tags []TagDetails + StartCommit string } // PrepareRepository prepares the git repository for use @@ -47,15 +47,15 @@ func PrepareRepository(repo *GitRepository) error { u, err := url.Parse(repo.Name) if err != nil { Error("Unable to parse repository URL", map[string]interface{}{ - "error": err.Error(), - "url": repo.Name, + "error": err.Error(), + "url": repo.Name, }) return err } - + repo.LocalPath = fmt.Sprintf("/tmp/semver/%s/%s", u.Path, repo.Branch) - os.RemoveAll(repo.LocalPath) - + _ = os.RemoveAll(repo.LocalPath) // Ignore error - directory may not exist + repo.Handler, err = git.PlainClone(repo.LocalPath, false, &git.CloneOptions{ URL: repo.Name, ReferenceName: plumbing.NewBranchReferenceName(repo.Branch), @@ -66,11 +66,11 @@ func PrepareRepository(repo *GitRepository) error { }, Tags: git.AllTags, }) - + if err != nil { Error("Unable to clone repository", map[string]interface{}{ - "error": err.Error(), - "url": repo.Name, + "error": err.Error(), + "url": repo.Name, }) return err } @@ -79,14 +79,20 @@ func PrepareRepository(repo *GitRepository) error { repo.Handler, err = git.PlainOpen(repo.LocalPath) if err != nil { Error("Unable to open local repository", map[string]interface{}{ - "error": err.Error(), - "path": repo.LocalPath, + "error": err.Error(), + "path": repo.LocalPath, }) return err } } - - os.Chdir(repo.LocalPath) + + if err := os.Chdir(repo.LocalPath); err != nil { + Error("Unable to change directory", map[string]interface{}{ + "error": err.Error(), + "path": repo.LocalPath, + }) + return err + } return nil } @@ -105,14 +111,14 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) { if err != nil { return []CommitDetails{}, err } - + commitsList, err := repo.Handler.Log(&git.LogOptions{From: ref.Hash()}) if err != nil { return []CommitDetails{}, err } var tmpResults []CommitDetails - commitsList.ForEach(func(c *object.Commit) error { + if err := commitsList.ForEach(func(c *object.Commit) error { tmpResults = append(tmpResults, CommitDetails{ Hash: c.Hash.String(), Author: c.Author.String(), @@ -123,17 +129,19 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) { return tmpResults[i].Timestamp.Unix() < tmpResults[j].Timestamp.Unix() }) return nil - }) + }); err != nil { + return []CommitDetails{}, err + } Debug("Listing commits", map[string]interface{}{"commits": tmpResults}) - + // Filter commits starting from the specified commit if provided if repo.StartCommit != "" { for commitId, cmt := range tmpResults { if cmt.Hash == repo.StartCommit { Debug("Found commit match", map[string]interface{}{ "commit": cmt.Hash, - "index": commitId, + "index": commitId, }) repo.Commits = tmpResults[commitId:] break @@ -150,32 +158,32 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) { // ListExistingTags lists all tags in the repository func ListExistingTags(repo *GitRepository) { 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 } - + refs, err := repo.Handler.Tags() if err != nil { Error("Unable to list tags", map[string]interface{}{"error": err.Error()}) return } - + if err := refs.ForEach(func(ref *plumbing.Reference) error { repo.Tags = append(repo.Tags, TagDetails{ Name: ref.Name().Short(), Hash: ref.Hash().String(), }) - + Debug("Found tag", map[string]interface{}{ - "tag": ref.Name().Short(), + "tag": ref.Name().Short(), "hash": ref.Hash().String(), }) - + return nil }); err != nil { Error("Error iterating tags", map[string]interface{}{"error": err.Error()}) } -} \ No newline at end of file +} diff --git a/cmd/utils/github.go b/cmd/utils/github.go index 79dd276..2e9742c 100644 --- a/cmd/utils/github.go +++ b/cmd/utils/github.go @@ -219,20 +219,23 @@ func downloadBinary(url string) (string, error) { if strings.HasSuffix(url, ".tar.gz") { // For tar.gz, we need to extract the binary if err := extractTarGz(resp.Body, tempFile); err != nil { - tempFile.Close() - os.Remove(tempPath) + _ = tempFile.Close() + _ = os.Remove(tempPath) return "", err } } else { // Direct binary download if _, err := io.Copy(tempFile, resp.Body); err != nil { - tempFile.Close() - os.Remove(tempPath) + _ = tempFile.Close() + _ = os.Remove(tempPath) return "", err } } - tempFile.Close() + if err := tempFile.Close(); err != nil { + _ = os.Remove(tempPath) + return "", err + } return tempPath, nil } @@ -250,10 +253,12 @@ func extractTarGz(r io.Reader, destFile *os.File) error { defer os.Remove(archivePath) if _, err := io.Copy(archiveFile, r); err != nil { - archiveFile.Close() + _ = archiveFile.Close() + return err + } + if err := archiveFile.Close(); err != nil { return err } - archiveFile.Close() // Extract using tar command extractDir, err := os.MkdirTemp("", "semver-generator-extract-*") @@ -336,6 +341,7 @@ var runCommandFunc = func(cmdStr string) error { // replaceBinary replaces the current binary with the new one func replaceBinary(newBinary, currentBinary string) error { // Make the new binary executable + // #nosec G302 -- 0755 is required for executable binaries if err := os.Chmod(newBinary, 0755); err != nil { return err } @@ -350,13 +356,17 @@ func replaceBinary(newBinary, currentBinary string) error { } // copyFile copies a file from src to dst +// Note: This function is only called internally with controlled paths from +// os.CreateTemp and os.Executable, not with user-supplied paths. func copyFile(src, dst string) error { + // #nosec G304 -- src is from os.CreateTemp, not user input srcFile, err := os.Open(src) if err != nil { return err } defer srcFile.Close() + // #nosec G304 -- dst is from os.Executable, not user input dstFile, err := os.Create(dst) if err != nil { return err @@ -368,6 +378,7 @@ func copyFile(src, dst string) error { } // Make executable + // #nosec G302 -- 0755 is required for executable binaries return os.Chmod(dst, 0755) } @@ -409,7 +420,10 @@ func parseVersionParts(v string) []int { for _, p := range parts { var num int - fmt.Sscanf(p, "%d", &num) + if _, err := fmt.Sscanf(p, "%d", &num); err != nil { + // If parsing fails, use 0 for this part + num = 0 + } result = append(result, num) } diff --git a/cmd/utils/semver.go b/cmd/utils/semver.go index c09cd33..275e8f4 100644 --- a/cmd/utils/semver.go +++ b/cmd/utils/semver.go @@ -13,6 +13,7 @@ func CalculateSemver( initialSemver SemVer, respectExisting bool, strictMode bool, + tagPrefixes []string, ) SemVer { semver := initialSemver @@ -22,10 +23,10 @@ func CalculateSemver( for _, tagHash := range tags { if commit.Hash == tagHash.Hash { Debug("Found existing tag", map[string]interface{}{ - "tag": tagHash.Name, + "tag": tagHash.Name, "commit": strings.TrimSuffix(commit.Message, "\n"), }) - semver = ParseExistingSemver(tagHash.Name, semver) + semver = ParseExistingSemver(tagHash.Name, semver, tagPrefixes) continue } } @@ -35,7 +36,7 @@ func CalculateSemver( if !strictMode { semver.Patch++ Debug("Incrementing patch (DEFAULT)", map[string]interface{}{ - "commit": strings.TrimSuffix(commit.Message, "\n"), + "commit": strings.TrimSuffix(commit.Message, "\n"), "semver": FormatSemver(semver), }) } @@ -55,44 +56,44 @@ func CalculateSemver( semver.EnableReleaseCandidate = false semver.Release = 0 Debug("Incrementing major (WORDING)", map[string]interface{}{ - "commit": strings.TrimSuffix(commit.Message, "\n"), + "commit": strings.TrimSuffix(commit.Message, "\n"), "semver": FormatSemver(semver), }) continue } - + if matchMinor { semver.Minor++ semver.Patch = 1 semver.EnableReleaseCandidate = false semver.Release = 0 Debug("Incrementing minor (WORDING)", map[string]interface{}{ - "commit": strings.TrimSuffix(commit.Message, "\n"), + "commit": strings.TrimSuffix(commit.Message, "\n"), "semver": FormatSemver(semver), }) continue } - + if matchReleaseCandidate { semver.Release++ semver.Patch = 1 semver.EnableReleaseCandidate = true Debug("Incrementing release candidate (WORDING)", map[string]interface{}{ - "commit": strings.TrimSuffix(commit.Message, "\n"), + "commit": strings.TrimSuffix(commit.Message, "\n"), "semver": FormatSemver(semver), }) continue } - + if matchPatch { semver.Patch++ Debug("Incrementing patch (WORDING)", map[string]interface{}{ - "commit": strings.TrimSuffix(commit.Message, "\n"), + "commit": strings.TrimSuffix(commit.Message, "\n"), "semver": FormatSemver(semver), }) continue } } - + return semver -} \ No newline at end of file +} diff --git a/cmd/utils/semver_test.go b/cmd/utils/semver_test.go index d7035aa..b179dd5 100644 --- a/cmd/utils/semver_test.go +++ b/cmd/utils/semver_test.go @@ -19,7 +19,7 @@ func TestCalculateSemver(t *testing.T) { // More sophisticated mock implementation for testing for _, h := range haystack { // Check for substring match to better simulate fuzzy search - if h == needle || (len(h) >= 3 && len(needle) >= 3 && + if h == needle || (len(h) >= 3 && len(needle) >= 3 && (h[:3] == needle[:3] || h[len(h)-3:] == needle[len(needle)-3:])) { return []string{h} } @@ -29,7 +29,7 @@ func TestCalculateSemver(t *testing.T) { // Test data now := time.Now() - + // Common wording and blacklist for all tests wording := Wording{ Patch: []string{"update", "fix", "initial"}, @@ -49,6 +49,7 @@ func TestCalculateSemver(t *testing.T) { initialSemver SemVer respectExisting bool strictMode bool + tagPrefixes []string want SemVer }{ { @@ -76,11 +77,12 @@ func TestCalculateSemver(t *testing.T) { initialSemver: SemVer{}, respectExisting: true, strictMode: false, + tagPrefixes: []string{}, want: SemVer{ - Major: 2, - Minor: 0, - Patch: 1, // Initial tag 2.0.0 + one patch increment - Release: 1, + Major: 2, + Minor: 0, + Patch: 1, // Initial tag 2.0.0 + one patch increment + Release: 1, EnableReleaseCandidate: true, }, }, @@ -109,11 +111,12 @@ func TestCalculateSemver(t *testing.T) { initialSemver: SemVer{}, respectExisting: true, strictMode: true, + tagPrefixes: []string{}, want: SemVer{ - Major: 2, - Minor: 0, - Patch: 1, // Initial tag 2.0.0 + patch from "update" keyword - Release: 1, + Major: 2, + Minor: 0, + Patch: 1, // Initial tag 2.0.0 + patch from "update" keyword + Release: 1, EnableReleaseCandidate: true, }, }, @@ -142,6 +145,7 @@ func TestCalculateSemver(t *testing.T) { initialSemver: SemVer{}, respectExisting: false, strictMode: false, + tagPrefixes: []string{}, want: SemVer{ Major: 0, Minor: 1, @@ -173,6 +177,7 @@ func TestCalculateSemver(t *testing.T) { initialSemver: SemVer{Major: 1}, respectExisting: false, strictMode: true, + tagPrefixes: []string{}, want: SemVer{ Major: 1, Minor: 1, @@ -199,6 +204,7 @@ func TestCalculateSemver(t *testing.T) { initialSemver: SemVer{}, respectExisting: false, strictMode: false, + tagPrefixes: []string{}, want: SemVer{ Major: 0, Minor: 0, @@ -225,6 +231,7 @@ func TestCalculateSemver(t *testing.T) { initialSemver: SemVer{}, respectExisting: false, strictMode: false, + tagPrefixes: []string{}, want: SemVer{ Major: 0, Minor: 0, @@ -233,6 +240,39 @@ func TestCalculateSemver(t *testing.T) { EnableReleaseCandidate: true, }, }, + { + name: "With prefixed tags should not be RC", + commits: []CommitDetails{ + { + Hash: "commit1", + Message: "tagged commit", + Timestamp: now.Add(-3 * time.Hour), + }, + { + Hash: "commit2", + Message: "another commit", + Timestamp: now.Add(-2 * time.Hour), + }, + }, + tags: []TagDetails{ + { + Name: "app-0.0.16", + Hash: "commit1", + }, + }, + wording: wording, + blacklist: blacklist, + initialSemver: SemVer{}, + respectExisting: true, + strictMode: true, // Use strict mode for predictable results + tagPrefixes: []string{"app-", "infra-"}, + want: SemVer{ + Major: 0, + Minor: 0, + Patch: 16, // From tag, no additional increments in strict mode + EnableReleaseCandidate: false, + }, + }, } for _, tt := range tests { @@ -245,6 +285,7 @@ func TestCalculateSemver(t *testing.T) { tt.initialSemver, tt.respectExisting, tt.strictMode, + tt.tagPrefixes, ) assert.Equal(t, tt.want.Major, got.Major, "Major version mismatch") @@ -254,4 +295,4 @@ func TestCalculateSemver(t *testing.T) { assert.Equal(t, tt.want.EnableReleaseCandidate, got.EnableReleaseCandidate, "EnableReleaseCandidate mismatch") }) } -} \ No newline at end of file +} diff --git a/cmd/utils/version.go b/cmd/utils/version.go index a0d05ac..6b9b03b 100644 --- a/cmd/utils/version.go +++ b/cmd/utils/version.go @@ -51,52 +51,102 @@ func FormatSemver(semver SemVer) string { var extractNumber = regexp.MustCompile("[0-9]+") +// StripTagPrefix removes configured prefixes from a tag name +// The "v" prefix is always stripped by default (e.g., v1.2.3 -> 1.2.3) +func StripTagPrefix(tagName string, prefixes []string) string { + result := tagName + + // Always strip "v" prefix by default + if strings.HasPrefix(result, "v") && len(result) > 1 { + // Only strip if followed by a digit (to avoid stripping "version-1.0.0") + if result[1] >= '0' && result[1] <= '9' { + result = result[1:] + Debug("Stripped default 'v' prefix from tag", map[string]interface{}{ + "original": tagName, + "result": result, + }) + } + } + + // Then strip any user-configured prefixes + for _, prefix := range prefixes { + if strings.HasPrefix(result, prefix) { + result = strings.TrimPrefix(result, prefix) + Debug("Stripped prefix from tag", map[string]interface{}{ + "original": tagName, + "prefix": prefix, + "result": result, + }) + break // Only strip one prefix + } + } + return result +} + // ParseExistingSemver parses a semantic version from a tag name -func ParseExistingSemver(tagName string, currentSemver SemVer) SemVer { +func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string) SemVer { Debug("Parsing existing semver", map[string]interface{}{"tag": tagName}) - - tagNameParts := strings.Split(tagName, ".") + + // Strip configured prefixes before parsing + cleanTagName := StripTagPrefix(tagName, prefixes) + + // Check for release candidate pattern (-rc.X) before splitting + isReleaseCandidate := false + rcVersion := 0 + if idx := strings.Index(cleanTagName, "-rc."); idx != -1 { + isReleaseCandidate = true + rcPart := cleanTagName[idx+4:] // Get everything after "-rc." + rcMatches := extractNumber.FindAllString(rcPart, 1) + if len(rcMatches) > 0 { + rcVersion, _ = strconv.Atoi(rcMatches[0]) + } + // Remove the RC suffix for version parsing + cleanTagName = cleanTagName[:idx] + Debug("Detected release candidate", map[string]interface{}{ + "rc_version": rcVersion, + "clean_tag_name": cleanTagName, + }) + } + + tagNameParts := strings.Split(cleanTagName, ".") if len(tagNameParts) < 3 { Debug("Unable to parse incompatible semver (non x.y.z)", map[string]interface{}{"tag": tagName}) return currentSemver } - + semanticVersion := SemVer{} - + // Extract major version majorMatches := extractNumber.FindAllString(tagNameParts[0], -1) if len(majorMatches) > 0 { semanticVersion.Major, _ = strconv.Atoi(majorMatches[0]) } - + // Extract minor version minorMatches := extractNumber.FindAllString(tagNameParts[1], -1) if len(minorMatches) > 0 { semanticVersion.Minor, _ = strconv.Atoi(minorMatches[0]) } - + // Extract patch version patchMatches := extractNumber.FindAllString(tagNameParts[2], -1) if len(patchMatches) > 0 { semanticVersion.Patch, _ = strconv.Atoi(patchMatches[0]) } - - // Extract release candidate version if present - if len(tagNameParts) > 3 { - releaseMatches := extractNumber.FindAllString(tagNameParts[3], -1) - if len(releaseMatches) > 0 { - semanticVersion.Release, _ = strconv.Atoi(releaseMatches[0]) - semanticVersion.EnableReleaseCandidate = true - } + + // Set release candidate if detected + if isReleaseCandidate { + semanticVersion.Release = rcVersion + semanticVersion.EnableReleaseCandidate = true } - + return semanticVersion } // CheckMatches checks if any of the targets match the content func CheckMatches(content []string, targets []string, blacklist []string) bool { contentStr := strings.Join(content, " ") - + // First check if any target matches hasMatch := false for _, tgt := range targets { @@ -104,8 +154,8 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool { if len(matches) > 0 { hasMatch = true Debug("Found match", map[string]interface{}{ - "target": tgt, - "match": strings.Join(matches, ","), + "target": tgt, + "match": strings.Join(matches, ","), "content": contentStr, }) break @@ -117,14 +167,14 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool { for _, blacklistTerm := range blacklist { if strings.Contains(strings.ToLower(contentStr), strings.ToLower(blacklistTerm)) { Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{ - "content": contentStr, + "content": contentStr, "blacklist_term": blacklistTerm, }) return false } } } - + return hasMatch } @@ -132,4 +182,4 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool { var FuzzyFind = func(needle string, haystack []string) []string { // This will be replaced with the actual implementation in main.go return nil -} \ No newline at end of file +} diff --git a/cmd/utils/version_test.go b/cmd/utils/version_test.go index df4f394..414bea0 100644 --- a/cmd/utils/version_test.go +++ b/cmd/utils/version_test.go @@ -58,15 +58,17 @@ func TestParseExistingSemver(t *testing.T) { InitLogger(false) tests := []struct { - name string - tagName string + name string + tagName string currentSemver SemVer - want SemVer + prefixes []string + want SemVer }{ { - name: "Standard semver", - tagName: "1.2.3", + name: "Standard semver", + tagName: "1.2.3", currentSemver: SemVer{}, + prefixes: []string{}, want: SemVer{ Major: 1, Minor: 2, @@ -74,9 +76,10 @@ func TestParseExistingSemver(t *testing.T) { }, }, { - name: "With v prefix", - tagName: "v2.3.4", + name: "With v prefix configured", + tagName: "v2.3.4", currentSemver: SemVer{}, + prefixes: []string{"v"}, want: SemVer{ Major: 2, Minor: 3, @@ -84,9 +87,32 @@ func TestParseExistingSemver(t *testing.T) { }, }, { - name: "With release candidate", - tagName: "3.4.5-rc.2", + name: "With app- prefix configured", + tagName: "app-1.2.3", currentSemver: SemVer{}, + prefixes: []string{"app-", "infra-"}, + want: SemVer{ + Major: 1, + Minor: 2, + Patch: 3, + }, + }, + { + name: "With prefix but not in config - should still parse numbers", + tagName: "v2.3.4", + currentSemver: SemVer{}, + prefixes: []string{}, // v not in prefixes + want: SemVer{ + Major: 2, + Minor: 3, + Patch: 4, + }, + }, + { + name: "With release candidate", + tagName: "3.4.5-rc.2", + currentSemver: SemVer{}, + prefixes: []string{}, want: SemVer{ Major: 3, Minor: 4, @@ -95,6 +121,31 @@ func TestParseExistingSemver(t *testing.T) { EnableReleaseCandidate: true, }, }, + { + name: "With prefix and release candidate", + tagName: "app-1.0.0-rc.3", + currentSemver: SemVer{}, + prefixes: []string{"app-"}, + want: SemVer{ + Major: 1, + Minor: 0, + Patch: 0, + Release: 3, + EnableReleaseCandidate: true, + }, + }, + { + name: "Prefixed tag without RC should NOT be RC", + tagName: "app-0.0.16", + currentSemver: SemVer{}, + prefixes: []string{"app-"}, + want: SemVer{ + Major: 0, + Minor: 0, + Patch: 16, + EnableReleaseCandidate: false, + }, + }, { name: "Invalid format", tagName: "not-a-semver", @@ -103,6 +154,7 @@ func TestParseExistingSemver(t *testing.T) { Minor: 1, Patch: 1, }, + prefixes: []string{}, want: SemVer{ Major: 1, Minor: 1, @@ -117,6 +169,7 @@ func TestParseExistingSemver(t *testing.T) { Minor: 5, Patch: 5, }, + prefixes: []string{}, want: SemVer{ Major: 5, Minor: 5, @@ -127,7 +180,7 @@ func TestParseExistingSemver(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ParseExistingSemver(tt.tagName, tt.currentSemver) + got := ParseExistingSemver(tt.tagName, tt.currentSemver, tt.prefixes) assert.Equal(t, tt.want.Major, got.Major, "Major version mismatch") assert.Equal(t, tt.want.Minor, got.Minor, "Minor version mismatch") assert.Equal(t, tt.want.Patch, got.Patch, "Patch version mismatch") @@ -196,4 +249,4 @@ func TestCheckMatches(t *testing.T) { assert.Equal(t, tt.want, got) }) } -} \ No newline at end of file +} diff --git a/config-release.yaml b/config-release.yaml index 3d4cd4c..af63cab 100644 --- a/config-release.yaml +++ b/config-release.yaml @@ -5,6 +5,11 @@ force: existing: true strict: false commit: 960207e4677476ad31a9f389f74eaf9f33d49613 +# tag_prefixes: (optional) prefixes to strip from existing tags +# Note: "v" prefix is always stripped automatically +# tag_prefixes: +# - "app-" +# - "service-" wording: patch: - update diff --git a/config.yaml b/config.yaml index 3d2559b..be885dc 100644 --- a/config.yaml +++ b/config.yaml @@ -8,6 +8,10 @@ blacklist: - "Merge pull request" - "feature/" - "feature:" +tag_prefixes: + # Note: "v" prefix is stripped automatically, no need to include it here + - "app-" + - "infra-" wording: patch: - update