Compare commits

...
11 Commits
Author SHA1 Message Date
lukaszraczylo 6e5afc22d4 ci: build with Go 1.27
The dependency update pulled a module that needs Go 1.26. The workflows pinned Go 1.25, so the update step could not run.
2026-10-02 16:18:42 +01:00
github-actions[bot] c0201f2f6b chore: pin action.yml Docker image to v1.18.4 2026-10-02 02:02:10 +00:00
lukaszraczyloandGitHub 353c5b22d9 Update go.mod and go.sum (#67) 2026-10-02 02:57:15 +01:00
github-actions[bot] 4dc18e6d7c chore: pin action.yml Docker image to v1.18.2 2026-09-25 18:40:43 +00:00
lukaszraczylo f7ae4c042f ci(release): move the v1 tag onto the image pin commit
The release job moves the rolling v1 tag to the release commit. The
update-action-version job then pins action.yml to the new image in a
later commit. So v1 always pointed at a commit that pinned the
previous image, and "@v1" users ran the previous release.

The job now checks out the released commit, pushes the pin commit with
"git push origin HEAD:main", and moves v1 onto it after that push
succeeds. If main moved during the release, the push fails and v1
stays where it is. The job runs only for main, and a concurrency group
queues overlapping release runs.

Claude-Session: https://claude.ai/code/session_01Qq1gbqQek1Zjtofo4VDRdW
2026-09-25 19:35:52 +01:00
lukaszraczylo e90191de4c feat(matching): add an opt-in regex keyword matching mode
Fuzzy matching compares each keyword with every word of the commit
message. A word matches if it contains the keyword's letters in order.
Thus "feat" matches "fix(delegation):" and "major" matches
"deps(majors):", which gives false minor and major versions.

The new top-level config key "matching" accepts "fuzzy" (the default)
or "regex". In regex mode every wording keyword is a Go regular
expression. The tool compiles all of them when it loads the config and
runs them against the full commit message, so "(?m)^" anchors a
pattern to the start of a line. An invalid value, an invalid pattern,
or any other config parse error in regex mode stops the tool with a
non-zero exit code. It never falls back to fuzzy matching.

Fuzzy mode does not change. The blacklist check moves into one helper
that both modes use. A replay of 15 repositories gives the same
versions as before. This change improves version precision for the
repositories that opt in.

Claude-Session: https://claude.ai/code/session_01Qq1gbqQek1Zjtofo4VDRdW
2026-09-25 19:35:48 +01:00
github-actions[bot] 9617b281a9 chore: pin action.yml Docker image to v1.17.20 2026-08-28 05:43:46 +00:00
lukaszraczyloandGitHub 6ba4a2674f Update go.mod and go.sum (#66) 2026-08-28 06:38:46 +01:00
github-actions[bot] e9507dd3ff chore: pin action.yml Docker image to v1.17.18 2026-08-13 03:49:16 +00:00
lukaszraczyloandGitHub b5eb8ae15a Update go.mod and go.sum (#65) 2026-08-13 04:43:59 +01:00
github-actions[bot] 0da4aa71e1 chore: pin action.yml Docker image to v1.17.16 2026-08-12 03:46:58 +00:00
15 changed files with 832 additions and 43 deletions
+1 -1
View File
@@ -14,6 +14,6 @@ jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: "1.25"
go-version: ">=1.27"
release-workflow: "release.yaml"
secrets: inherit
+1 -1
View File
@@ -13,4 +13,4 @@ jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: "1.25"
go-version: ">=1.27"
+20 -4
View File
@@ -14,11 +14,15 @@ permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: ">=1.25"
go-version: ">=1.27"
docker-enabled: true
rolling-release-tag: "v1"
semver-config: "config-release.yaml"
@@ -27,11 +31,11 @@ jobs:
update-action-version:
needs: release
runs-on: ubuntu-latest
if: needs.release.outputs.version != ''
if: github.ref == 'refs/heads/main' && needs.release.outputs.version != ''
steps:
- uses: actions/checkout@v4
with:
ref: main
ref: ${{ github.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update action.yml with release version
@@ -52,5 +56,17 @@ jobs:
echo "No changes to commit"
else
git commit -m "chore: pin action.yml Docker image to v${{ needs.release.outputs.version }}"
git push
# Push the pin commit onto main explicitly (not just "git push"):
# this checkout started from the released commit (github.sha), not
# main's tip, so if main moved in the meantime this push is a
# non-fast-forward and fails safely instead of overwriting it.
git push origin HEAD:main
# Only move the rolling tag once the pin commit is confirmed on
# main. The "release" job above already moved it to the release
# commit, but this job then commits action.yml's image pin on top
# of that, so without this the rolling tag would always be one
# commit behind, pinning the PREVIOUS image.
# "v1" must match the release job's rolling-release-tag input.
git tag -f v1 HEAD
git push -f origin refs/tags/v1
fi
+27 -1
View File
@@ -21,6 +21,7 @@ Project created overnight, to prove that management of semantic versioning is NO
- [Calculations example \[strict matching\]](#calculations-example-strict-matching)
- [Release candidates](#release-candidates)
- [Tag prefix stripping](#tag-prefix-stripping)
- [Matching modes](#matching-modes)
- [Example configuration](#example-configuration)
- [Good to knows](#good-to-knows)
- [Telemetry](#telemetry)
@@ -238,6 +239,30 @@ This is particularly useful when:
- Your CI/CD creates tags with component prefixes
- You want to track versions separately for different parts of your codebase
#### Matching modes
Config key `matching` controls how each keyword in `wording` is checked against a commit message. Allowed values are `fuzzy` (the default, also used when the key is absent) and `regex`. Any other value is a config error and `semver-gen` exits non-zero rather than silently falling back to `fuzzy`.
**`fuzzy` (default):** the commit message is split on whitespace into words, and a keyword matches as soon as one word contains the keyword's letters *in order*, case-insensitive — it does not need to be a whole word or an exact substring. This is convenient but has a real false-positive risk: keyword `feat` also matches the word `fix(delegation):` (f-e-a-t appear in order inside "fix(delegation):"), and keyword `major` matches `deps(majors):`. Short, generic keywords are the most likely to over-match; test your wording list against real commit messages before relying on it.
**`regex`:** every keyword in `wording.patch` / `wording.minor` / `wording.major` / `wording.release` is a Go RE2 regular expression (the standard library `regexp` package). Patterns are compiled once when the config is loaded — an invalid pattern makes `semver-gen` exit non-zero immediately, naming the wording list and the keyword. Each pattern is matched with `MatchString` against the **full raw commit message** (subject and body, with the original newlines, not the whitespace-split words `fuzzy` uses), so `(?m)^feat(\([^)]*\))?!?:` anchors to the start of a line. Matching is case-sensitive unless the pattern itself uses `(?i)`. The blacklist works exactly the same way in both modes.
Regex patterns must be quoted in YAML (for example `'(?m)^fix:'`), since unquoted `^`, `:` and other YAML-significant characters can break the file's syntax; a YAML syntax error makes the tool fall back to its defaults, the same as it already does for any unreadable config file.
```yaml
matching: regex
wording:
patch:
- '^fix(\([^)]*\))?:'
minor:
- '(?m)^feat(\([^)]*\))?!?:'
major:
# a trailer line anywhere in the commit body, e.g. "Semver-Major: dropped the v1 API"
- '(?mi)^semver-major:'
```
With this config, a subject line of `fix: correct off-by-one` bumps patch, `feat: add widget` bumps minor, and a body trailer of `Semver-Major: ...` bumps major regardless of case. `fix(delegation): route around dead worker` bumps patch too — the patch pattern's `(\([^)]*\))?` group allows an optional scope — but it does NOT bump minor, unlike `fuzzy` mode, where keyword `feat` matches it (see above).
#### Example configuration
```yaml
@@ -276,11 +301,12 @@ wording:
* `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.
* `matching`: how `wording` keywords are matched against commit messages — `fuzzy` (default) or `regex`. See [Matching modes](#matching-modes).
* `wording`: words the program should look for in the git commits to increment (patch|minor|major)
### Good to knows
* Word matching uses fuzzy search AND is case INSENSITIVE
* Word matching uses fuzzy search AND is case INSENSITIVE by default; set `matching: regex` for anchored, case-sensitive regular expression matching instead — see [Matching modes](#matching-modes)
* I do not recommend using common words ( like "the" from the example configuration )
* You can specify env variable `LOG_LEVEL=debug` to see what exactly happens during the calculations
+1 -1
View File
@@ -36,4 +36,4 @@ outputs:
description: "Calculated semantic version"
runs:
using: "docker"
image: "docker://ghcr.io/lukaszraczylo/semver-generator:1.17.14"
image: "docker://ghcr.io/lukaszraczylo/semver-generator:1.18.4"
+11
View File
@@ -18,6 +18,7 @@ limitations under the License.
package cmd
import (
"errors"
"fmt"
"os"
@@ -96,6 +97,15 @@ func main() {
// Read configuration
config, err := utils.ReadConfig(repo.LocalConfigFile)
if err != nil {
// A bad "matching" value or an invalid regex keyword is a real
// misconfiguration, not a missing-file situation: fail loudly
// instead of silently falling back to fuzzy matching.
if errors.Is(err, utils.ErrInvalidMatchingConfig) {
utils.Critical("Invalid matching configuration", map[string]interface{}{
"error": err.Error(),
})
os.Exit(1)
}
utils.Error("Unable to find config file. Using defaults and flags.", map[string]interface{}{
"file": repo.LocalConfigFile,
})
@@ -140,6 +150,7 @@ func main() {
repo.GitRepo.Commits,
repo.GitRepo.Tags,
repo.Config.Wording,
repo.Config.Matcher,
repo.Config.Blacklist,
repo.Semver,
params.varExisting || repo.Config.Force.Existing,
+44 -4
View File
@@ -30,6 +30,8 @@ type Config struct {
Force Force
Blacklist []string
TagPrefixes []string // Prefixes to strip from tags before parsing (e.g., "app-", "infra-", "v")
Matching string // Effective matching mode: MatchingFuzzy (default) or MatchingRegex
Matcher KeywordMatcher
}
// ReadConfig reads the configuration from a file
@@ -43,19 +45,57 @@ func ReadConfig(file string) (*Config, error) {
return config, err
}
// Read "matching" first, right after ReadInConfig, so every later parse
// error below can tell whether regex mode is in play. viper.Get (not
// GetString) lets us reject a non-scalar value, such as a YAML list or
// map, as a config error instead of silently treating it as "fuzzy".
switch rawMatching := viper.Get("matching"); v := rawMatching.(type) {
case nil:
config.Matching = MatchingFuzzy
case string:
switch v {
case "":
config.Matching = MatchingFuzzy
case MatchingFuzzy, MatchingRegex:
config.Matching = v
default:
return config, fmt.Errorf("%w: invalid matching value %q, allowed values are %q and %q", ErrInvalidMatchingConfig, v, MatchingFuzzy, MatchingRegex)
}
default:
return config, fmt.Errorf("%w: matching config must be a string, got %v (%T)", ErrInvalidMatchingConfig, v, v)
}
// In regex mode there is no fuzzy fallback: any config parse error below
// (wording/force/blacklist/tag_prefixes) is a real misconfiguration, so
// wrap it with ErrInvalidMatchingConfig, which main.go treats as fatal.
// In fuzzy mode (default), behaviour is unchanged: the error is returned
// as-is and the caller logs it and continues with defaults.
wrapIfRegex := func(err error) error {
if err == nil || config.Matching != MatchingRegex {
return err
}
return fmt.Errorf("%w: %v", ErrInvalidMatchingConfig, err)
}
if err := viper.UnmarshalKey("wording", &config.Wording); err != nil {
return config, fmt.Errorf("error parsing wording config: %w", err)
return config, wrapIfRegex(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)
return config, wrapIfRegex(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)
return config, wrapIfRegex(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, wrapIfRegex(fmt.Errorf("error parsing tag_prefixes config: %w", err))
}
matcher, err := NewKeywordMatcher(config.Matching, config.Wording)
if err != nil {
return config, err
}
config.Matcher = matcher
return config, nil
}
+198
View File
@@ -1,10 +1,13 @@
package utils
import (
"errors"
"os"
"testing"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyForcedVersioning(t *testing.T) {
@@ -199,3 +202,198 @@ wording:
_, err = ReadConfig("non-existent-file.yaml")
assert.Error(t, err)
}
func writeTempConfig(t *testing.T, content string) string {
t.Helper()
tempFile, err := os.CreateTemp("", "semver-config-matching-*.yaml")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
if _, err := tempFile.Write([]byte(content)); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
if err := tempFile.Close(); err != nil {
t.Fatalf("Failed to close temp file: %v", err)
}
t.Cleanup(func() {
if err := os.Remove(tempFile.Name()); err != nil && !os.IsNotExist(err) {
t.Logf("failed to remove temp config %s: %v", tempFile.Name(), err)
}
})
return tempFile.Name()
}
func TestReadConfigMatching(t *testing.T) {
InitLogger(false)
t.Run("absent matching key defaults to fuzzy", func(t *testing.T) {
path := writeTempConfig(t, `
wording:
patch:
- feat
`)
config, err := ReadConfig(path)
assert.NoError(t, err)
assert.Equal(t, MatchingFuzzy, config.Matching)
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
// Behavioural check, not just the type: absent/fuzzy mode means
// "fix(delegation): x" fuzzy-matches keyword "feat", using the real
// fuzzy library (the same one main.go wires up), not a stand-in.
originalFuzzyFind := FuzzyFind
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
FuzzyFind = fuzzy.FindNormalizedFold
assert.True(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Patch, nil))
})
t.Run("explicit fuzzy", func(t *testing.T) {
path := writeTempConfig(t, `
matching: fuzzy
wording:
patch:
- update
`)
config, err := ReadConfig(path)
assert.NoError(t, err)
assert.Equal(t, MatchingFuzzy, config.Matching)
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
// Behavioural check, not just the type: same as the absent-key
// subtest above, using the real fuzzy library.
originalFuzzyFind := FuzzyFind
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
FuzzyFind = fuzzy.FindNormalizedFold
assert.True(t, config.Matcher.Matches("fix(delegation): x", []string{"feat"}, nil))
})
t.Run("explicit regex compiles keywords", func(t *testing.T) {
path := writeTempConfig(t, `
matching: regex
wording:
patch:
- "^fix:"
minor:
- "(?m)^feat(\\([^)]*\\))?!?:"
major:
- "(?mi)^semver-major:"
`)
config, err := ReadConfig(path)
assert.NoError(t, err)
assert.Equal(t, MatchingRegex, config.Matching)
assert.IsType(t, RegexMatcher{}, config.Matcher)
// Behavioural check: "fix(delegation): x" matches fuzzy keyword "feat"
// (see the absent/fuzzy subtest below) but must NOT match this
// anchored regex pattern.
assert.False(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Minor, nil))
})
t.Run("unknown matching value errors naming value and allowed values", func(t *testing.T) {
path := writeTempConfig(t, `
matching: substring
wording:
patch:
- update
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "substring")
assert.Contains(t, err.Error(), MatchingFuzzy)
assert.Contains(t, err.Error(), MatchingRegex)
})
// Fix 1: an invalid "matching" value must be rejected immediately, before
// wording/force/blacklist/tag_prefixes are ever parsed. Previously a
// mistyped value like "substring" was accepted as-is by the case string
// branch, so a malformed wording section below it returned an unwrapped
// parse error instead of ErrInvalidMatchingConfig, and main() silently
// fell back to fuzzy mode instead of failing loudly.
t.Run("invalid matching value combined with malformed wording section still errors as ErrInvalidMatchingConfig", func(t *testing.T) {
path := writeTempConfig(t, `
matching: substring
wording: "not-a-map"
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "substring")
})
t.Run("invalid regex errors naming wording list and keyword", func(t *testing.T) {
path := writeTempConfig(t, `
matching: regex
wording:
major:
- "[unterminated("
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "major")
assert.Contains(t, err.Error(), "[unterminated(")
})
// Fix 2: no silent fuzzy fallback in regex mode. A malformed "wording"
// section (a scalar instead of a map) fails viper.UnmarshalKey, not
// NewKeywordMatcher. In regex mode that must still be a fatal,
// ErrInvalidMatchingConfig-wrapped error — main() exits non-zero on it
// (see cmd/main.go's errors.Is(err, utils.ErrInvalidMatchingConfig)
// check) instead of silently logging and falling back to fuzzy matching.
t.Run("regex mode + malformed wording section is a fatal error, not a fuzzy fallback", func(t *testing.T) {
path := writeTempConfig(t, `
matching: regex
wording: "not-a-map"
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig, "main() only treats ErrInvalidMatchingConfig as fatal; anything else falls back to fuzzy and continues")
assert.Contains(t, err.Error(), "wording")
})
// Contrast case: the exact same malformed "wording" section in fuzzy mode
// (the default) must behave exactly as it did before regex mode existed —
// an error is returned, but it is NOT wrapped in ErrInvalidMatchingConfig,
// so main() logs it and continues with defaults rather than exiting.
t.Run("fuzzy mode + malformed wording section stays non-fatal, same as today", func(t *testing.T) {
path := writeTempConfig(t, `
wording: "not-a-map"
`)
_, err := ReadConfig(path)
assert.Error(t, err)
assert.False(t, errors.Is(err, ErrInvalidMatchingConfig), "fuzzy mode must not fail loudly for non-matching config errors")
})
// Fix 4: a non-scalar "matching" value (a YAML list or map) must be an
// ErrInvalidMatchingConfig error naming the value, never a silent fuzzy
// fallback.
t.Run("matching as a YAML list is an error naming the value", func(t *testing.T) {
path := writeTempConfig(t, `
matching:
- fuzzy
- regex
wording:
patch:
- update
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "fuzzy")
assert.Contains(t, err.Error(), "regex")
})
t.Run("matching as a YAML map is an error naming the value", func(t *testing.T) {
path := writeTempConfig(t, `
matching:
mode: regex
wording:
patch:
- update
`)
_, err := ReadConfig(path)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "mode")
})
}
+119
View File
@@ -0,0 +1,119 @@
// Package utils implements semver-gen's configuration loading, git access,
// commit-keyword matching (fuzzy and regex) and version calculation.
package utils
import (
"errors"
"fmt"
"regexp"
"strings"
)
// Matching mode values accepted by the "matching" config key.
const (
MatchingFuzzy = "fuzzy"
MatchingRegex = "regex"
)
// ErrInvalidMatchingConfig is wrapped by any error NewKeywordMatcher returns.
// Callers use errors.Is to tell a bad "matching" value or a bad regex keyword
// apart from other, non-fatal config problems.
var ErrInvalidMatchingConfig = errors.New("invalid matching configuration")
// KeywordMatcher decides whether a commit message matches one of a wording
// list's keywords, honoring the blacklist. CalculateSemver picks one
// implementation, chosen once from config, instead of branching on the
// matching mode itself.
type KeywordMatcher interface {
Matches(commitMessage string, keywords []string, blacklist []string) bool
}
// FuzzyMatcher reproduces the original matching behaviour: the commit message
// is split into whitespace-separated words and each keyword is fuzzy-matched
// (letters in order, case-insensitive) against those words via FuzzyFind.
type FuzzyMatcher struct{}
// Matches implements KeywordMatcher.
func (FuzzyMatcher) Matches(commitMessage string, keywords []string, blacklist []string) bool {
return CheckMatches(strings.Fields(commitMessage), keywords, blacklist)
}
// RegexMatcher matches keywords, each a Go RE2 regular expression compiled
// once up front, against the full raw commit message (subject and body,
// newlines preserved).
type RegexMatcher struct {
compiled map[string]*regexp.Regexp
}
// Matches implements KeywordMatcher.
func (m RegexMatcher) Matches(commitMessage string, keywords []string, blacklist []string) bool {
hasMatch := false
for _, keyword := range keywords {
re, ok := m.compiled[keyword]
if !ok {
// NewKeywordMatcher compiles every keyword of every wording list up
// front, so this means Matches was called with a keyword that
// wasn't part of the wording the matcher was built from. Log it
// loudly instead of silently skipping, and treat it as no-match
// rather than panicking on a nil regexp.
Error("Regex keyword was never compiled; treating as no-match", map[string]interface{}{
"keyword": keyword,
})
continue
}
if re.MatchString(commitMessage) {
hasMatch = true
break
}
}
if !hasMatch {
return false
}
// Match blacklist semantics exactly the same way CheckMatches (fuzzy mode)
// does: whitespace-normalized content, so a blacklist term split across a
// newline or tab in the raw commit message still suppresses the match.
contentStr := strings.Join(strings.Fields(commitMessage), " ")
return !isBlacklisted(contentStr, blacklist)
}
// NewKeywordMatcher builds the KeywordMatcher selected by matching ("" and
// "fuzzy" both mean fuzzy, "regex" means regex; anything else is an error).
// In regex mode every keyword across all four wording lists is compiled
// exactly once here, so a bad pattern is caught at config-load time instead
// of failing mid-run. Compile and validation errors wrap
// ErrInvalidMatchingConfig so callers can fail loudly on them.
func NewKeywordMatcher(matching string, wording Wording) (KeywordMatcher, error) {
switch matching {
case "", MatchingFuzzy:
return FuzzyMatcher{}, nil
case MatchingRegex:
wordingLists := []struct {
name string
keywords []string
}{
{"patch", wording.Patch},
{"minor", wording.Minor},
{"major", wording.Major},
{"release", wording.Release},
}
compiled := make(map[string]*regexp.Regexp)
for _, list := range wordingLists {
for _, keyword := range list.keywords {
if _, alreadyCompiled := compiled[keyword]; alreadyCompiled {
continue
}
re, err := regexp.Compile(keyword)
if err != nil {
return nil, fmt.Errorf("%w: invalid regex in wording.%s keyword %q: %v", ErrInvalidMatchingConfig, list.name, keyword, err)
}
compiled[keyword] = re
}
}
return RegexMatcher{compiled: compiled}, nil
default:
return nil, fmt.Errorf("%w: invalid matching value %q, allowed values are %q and %q", ErrInvalidMatchingConfig, matching, MatchingFuzzy, MatchingRegex)
}
}
+220
View File
@@ -0,0 +1,220 @@
package utils
import (
"testing"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewKeywordMatcher(t *testing.T) {
InitLogger(false)
wording := Wording{
Patch: []string{"^fix:"},
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
Major: []string{"(?mi)^semver-major:"},
}
t.Run("empty matching defaults to fuzzy", func(t *testing.T) {
m, err := NewKeywordMatcher("", wording)
assert.NoError(t, err)
assert.IsType(t, FuzzyMatcher{}, m)
// Behavioural check, not just the type: fuzzy matching means
// "fix(delegation): x" still matches keyword "feat" (f-e-a-t appear
// in order inside "fix(delegation):"), using the real fuzzy library
// (the same one main.go wires up), not a stand-in.
originalFuzzyFind := FuzzyFind
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
FuzzyFind = fuzzy.FindNormalizedFold
assert.True(t, m.Matches("fix(delegation): x", []string{"feat"}, nil))
})
t.Run("fuzzy", func(t *testing.T) {
m, err := NewKeywordMatcher(MatchingFuzzy, wording)
assert.NoError(t, err)
assert.IsType(t, FuzzyMatcher{}, m)
// Behavioural check, not just the type: same as the empty-matching
// subtest above, using the real fuzzy library.
originalFuzzyFind := FuzzyFind
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
FuzzyFind = fuzzy.FindNormalizedFold
assert.True(t, m.Matches("fix(delegation): x", []string{"feat"}, nil))
})
t.Run("regex compiles every keyword once", func(t *testing.T) {
m, err := NewKeywordMatcher(MatchingRegex, wording)
assert.NoError(t, err)
assert.IsType(t, RegexMatcher{}, m)
// Behavioural check: the same input that fuzzy-matches "feat" does not
// match the anchored regex pattern in wording.Minor.
assert.False(t, m.Matches("fix(delegation): x", wording.Minor, nil))
})
t.Run("unknown value errors naming value and allowed values", func(t *testing.T) {
_, err := NewKeywordMatcher("weird", wording)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "weird")
assert.Contains(t, err.Error(), MatchingFuzzy)
assert.Contains(t, err.Error(), MatchingRegex)
})
t.Run("invalid regex errors naming wording list and keyword", func(t *testing.T) {
badWording := Wording{Major: []string{"[unterminated("}}
_, err := NewKeywordMatcher(MatchingRegex, badWording)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "major")
assert.Contains(t, err.Error(), "[unterminated(")
})
t.Run("invalid release regex errors naming wording list and keyword", func(t *testing.T) {
badWording := Wording{Release: []string{"[unterminated("}}
_, err := NewKeywordMatcher(MatchingRegex, badWording)
require.Error(t, err)
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
assert.Contains(t, err.Error(), "release")
assert.Contains(t, err.Error(), "[unterminated(")
})
}
func TestRegexMatcherMatches(t *testing.T) {
InitLogger(false)
t.Run("anchored feat subject matches", func(t *testing.T) {
m, err := NewKeywordMatcher(MatchingRegex, Wording{
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
})
assert.NoError(t, err)
got := m.Matches("feat: add widget\n\nsome body text", []string{`(?m)^feat(\([^)]*\))?!?:`}, nil)
assert.True(t, got)
})
t.Run("fix(delegation) subject does not match anchored feat pattern", func(t *testing.T) {
m, err := NewKeywordMatcher(MatchingRegex, Wording{
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
})
assert.NoError(t, err)
got := m.Matches("fix(delegation): route around dead worker", []string{`(?m)^feat(\([^)]*\))?!?:`}, nil)
assert.False(t, got)
})
t.Run("body trailer Semver-Major matches multiline case-insensitive pattern", func(t *testing.T) {
pattern := "(?mi)^semver-major:"
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
assert.NoError(t, err)
message := "fix: patch a bug\n\nDetails here.\n\nSemver-Major: breaking storage format\n"
got := m.Matches(message, []string{pattern}, nil)
assert.True(t, got)
})
t.Run("feat! subject does not match a trailer-only major pattern", func(t *testing.T) {
pattern := "(?mi)^semver-major:"
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
assert.NoError(t, err)
got := m.Matches("feat!: rework the API\n\nno trailer here", []string{pattern}, nil)
assert.False(t, got)
})
t.Run("case sensitivity: pattern without (?i) does not match different case", func(t *testing.T) {
pattern := "^FEAT:"
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
assert.NoError(t, err)
assert.False(t, m.Matches("feat: lowercase subject", []string{pattern}, nil))
assert.True(t, m.Matches("FEAT: uppercase subject", []string{pattern}, nil))
})
t.Run("blacklist still suppresses a regex match", func(t *testing.T) {
pattern := `(?m)^feat(\([^)]*\))?!?:`
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
assert.NoError(t, err)
message := "feat: add widget [skip-ci]"
assert.True(t, m.Matches(message, []string{pattern}, nil))
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip-ci"}))
})
t.Run("blacklist term split by a newline still suppresses, same as fuzzy mode", func(t *testing.T) {
pattern := `(?m)^feat(\([^)]*\))?!?:`
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
assert.NoError(t, err)
// Blacklist term is "skip ci", but the raw commit message has it split
// across a newline: "skip\nci". CheckMatches (fuzzy mode) would still
// catch this because it joins strings.Fields(msg) with a single space
// before the substring check; the regex-mode blacklist check must do
// the same.
message := "feat: add widget\n\nskip\nci"
assert.True(t, m.Matches(message, []string{pattern}, nil), "sanity: matches without blacklist")
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip ci"}))
})
t.Run("blacklist term split by a tab still suppresses, same as fuzzy mode", func(t *testing.T) {
pattern := `(?m)^feat(\([^)]*\))?!?:`
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
assert.NoError(t, err)
message := "feat: add widget\n\nskip\tci"
assert.True(t, m.Matches(message, []string{pattern}, nil), "sanity: matches without blacklist")
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip ci"}))
})
t.Run("full-message anchoring: mid-message keyword-looking text does not match anchored pattern", func(t *testing.T) {
// Discriminating case: if RegexMatcher ran MatchString per
// strings.Fields word instead of against the full raw message, the
// word "feat:" here would match "(?m)^feat...:" on its own (it starts
// a "word"), even though the actual commit subject starts with
// "docs:". Matching against the full message must not match.
pattern := `(?m)^feat(\([^)]*\))?!?:`
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
assert.NoError(t, err)
got := m.Matches("docs: explain feat: usage", []string{pattern}, nil)
assert.False(t, got)
})
t.Run("full-message anchoring: multiline trailer matches (?m) anchor on its own line", func(t *testing.T) {
// Discriminating case: strings.Fields on this message splits "BREAKING"
// and "CHANGE:" into separate words, neither of which starts with
// "BREAKING CHANGE:", so a per-word implementation would miss this.
// Matching against the full raw message (newlines kept) must match,
// because (?m) anchors "^" to the start of the trailer's own line.
pattern := `(?m)^BREAKING CHANGE:`
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
assert.NoError(t, err)
got := m.Matches("refactor: x\n\nBREAKING CHANGE: drop v1", []string{pattern}, nil)
assert.True(t, got)
})
t.Run("keyword missing from compiled map is a no-match, not a panic", func(t *testing.T) {
// The matcher was built from wording.Minor only; calling Matches with
// an unrelated keyword exercises the "not compiled" branch. It must
// log loudly (via the package's Error logger) rather than silently
// return, but it must not miss the check or panic on a nil regexp.
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{`(?m)^feat:`}})
assert.NoError(t, err)
assert.NotPanics(t, func() {
got := m.Matches("feat: add widget", []string{"^never-compiled:"}, nil)
assert.False(t, got)
})
})
}
func TestFuzzyMatcherMatches(t *testing.T) {
InitLogger(false)
originalFuzzyFind := FuzzyFind
defer func() { FuzzyFind = originalFuzzyFind }()
FuzzyFind = func(needle string, haystack []string) []string {
for _, h := range haystack {
if h == needle {
return []string{h}
}
}
return nil
}
m := FuzzyMatcher{}
assert.True(t, m.Matches("update dependencies", []string{"update", "fix"}, nil))
assert.False(t, m.Matches("chore dependencies", []string{"update", "fix"}, nil))
assert.False(t, m.Matches("update dependencies skip-ci", []string{"update", "fix"}, []string{"skip-ci"}))
}
+11 -5
View File
@@ -9,12 +9,19 @@ func CalculateSemver(
commits []CommitDetails,
tags []TagDetails,
wording Wording,
matcher KeywordMatcher,
blacklist []string,
initialSemver SemVer,
respectExisting bool,
strictMode bool,
tagPrefixes []string,
) SemVer {
// Default-preserving: callers that don't select a matcher get today's
// fuzzy behaviour rather than a nil-pointer panic.
if matcher == nil {
matcher = FuzzyMatcher{}
}
semver := initialSemver
startIndex := 0
@@ -57,11 +64,10 @@ func CalculateSemver(
}
// Check for keyword matches
commitSlice := strings.Fields(commit.Message)
matchPatch := CheckMatches(commitSlice, wording.Patch, blacklist)
matchMinor := CheckMatches(commitSlice, wording.Minor, blacklist)
matchMajor := CheckMatches(commitSlice, wording.Major, blacklist)
matchReleaseCandidate := CheckMatches(commitSlice, wording.Release, blacklist)
matchPatch := matcher.Matches(commit.Message, wording.Patch, blacklist)
matchMinor := matcher.Matches(commit.Message, wording.Minor, blacklist)
matchMajor := matcher.Matches(commit.Message, wording.Major, blacklist)
matchReleaseCandidate := matcher.Matches(commit.Message, wording.Release, blacklist)
// Apply version changes based on matches
if matchMajor {
+147
View File
@@ -4,6 +4,7 @@ import (
"testing"
"time"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/stretchr/testify/assert"
)
@@ -281,6 +282,7 @@ func TestCalculateSemver(t *testing.T) {
tt.commits,
tt.tags,
tt.wording,
FuzzyMatcher{},
tt.blacklist,
tt.initialSemver,
tt.respectExisting,
@@ -296,3 +298,148 @@ func TestCalculateSemver(t *testing.T) {
})
}
}
// TestCalculateSemverRegexModeEndToEnd exercises the whole config->matcher->
// CalculateSemver path in regex mode, with anchored conventional-commit
// patterns and a "Semver-Major:" trailer.
func TestCalculateSemverRegexModeEndToEnd(t *testing.T) {
InitLogger(false)
wording := Wording{
Patch: []string{`^fix:`},
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
Major: []string{`(?mi)^semver-major:`},
Release: []string{},
}
matcher, err := NewKeywordMatcher(MatchingRegex, wording)
assert.NoError(t, err)
// c4 ("fix(delegation): ...") is placed AFTER the major commit (c5) on
// purpose: the anchored minor pattern `(?m)^feat(\([^)]*\))?!?:` must not
// match a message that starts with "fix", so c4 contributes no version
// bump at all under correct regex matching. If a future regression made
// RegexMatcher match per-word instead of against the full raw message
// (the false positive fuzzy mode has, see matcher_test.go's
// "full-message anchoring" cases), c4 would wrongly bump Minor. With c4
// last, nothing runs after it to reset that spurious bump back to 0, so
// the final assertions below would catch it. (With c4 before c5, as it
// used to be, the major commit's Minor=0 reset would mask the same bug.)
now := time.Now()
commits := []CommitDetails{
{Hash: "c1", Message: "chore: init repo", Timestamp: now.Add(-5 * time.Hour)},
{Hash: "c2", Message: "fix: correct off-by-one", Timestamp: now.Add(-4 * time.Hour)},
{Hash: "c3", Message: "feat: add widget\n\nsome body text", Timestamp: now.Add(-3 * time.Hour)},
{Hash: "c5", Message: "docs: update README\n\nSemver-Major: breaking storage format", Timestamp: now.Add(-2 * time.Hour)},
{Hash: "c4", Message: "fix(delegation): route around dead worker", Timestamp: now.Add(-1 * time.Hour)},
}
got := CalculateSemver(
commits,
nil,
wording,
matcher,
nil,
SemVer{},
false,
true, // strict mode: only wording matches move the version
nil,
)
assert.Equal(t, 1, got.Major, "Major version mismatch")
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
assert.Equal(t, 1, got.Patch, "Patch version mismatch")
assert.Equal(t, 0, got.Release, "Release version mismatch")
assert.False(t, got.EnableReleaseCandidate)
}
// TestCalculateSemverRegexModeReleaseCandidateEndToEnd exercises
// wording.Release in regex mode end to end: a commit matching the release
// pattern must bump the release-candidate counter, exactly like fuzzy mode
// does for wording.Release matches.
func TestCalculateSemverRegexModeReleaseCandidateEndToEnd(t *testing.T) {
InitLogger(false)
wording := Wording{
Patch: []string{`^fix:`},
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
Major: []string{`(?mi)^semver-major:`},
Release: []string{`(?m)^release-candidate:`},
}
matcher, err := NewKeywordMatcher(MatchingRegex, wording)
assert.NoError(t, err)
now := time.Now()
commits := []CommitDetails{
{Hash: "c1", Message: "fix: correct off-by-one", Timestamp: now.Add(-2 * time.Hour)},
{Hash: "c2", Message: "release-candidate: cut rc for QA", Timestamp: now.Add(-1 * time.Hour)},
}
got := CalculateSemver(
commits,
nil,
wording,
matcher,
nil,
SemVer{},
false,
true, // strict mode: only wording matches move the version
nil,
)
assert.Equal(t, 0, got.Major, "Major version mismatch")
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
assert.Equal(t, 1, got.Patch, "Patch version mismatch")
assert.Equal(t, 1, got.Release, "Release version mismatch")
assert.True(t, got.EnableReleaseCandidate)
}
// TestCalculateSemverFuzzyModeUnchanged runs CalculateSemver with the real
// fuzzy.FindNormalizedFold implementation (the same one main.go wires up) to
// confirm fuzzy mode's results are unchanged by the matcher refactor.
func TestCalculateSemverFuzzyModeUnchanged(t *testing.T) {
InitLogger(false)
originalFuzzyFind := FuzzyFind
defer func() { FuzzyFind = originalFuzzyFind }()
FuzzyFind = fuzzy.FindNormalizedFold
wording := Wording{
Patch: []string{"fix"},
Minor: []string{"feat"},
Major: []string{"breaking"},
}
blacklist := []string{"skip-ci"}
now := time.Now()
commits := []CommitDetails{
{Hash: "c1", Message: "chore: init", Timestamp: now.Add(-6 * time.Hour)},
{Hash: "c2", Message: "fix: correct bug", Timestamp: now.Add(-5 * time.Hour)},
{Hash: "c3", Message: "feat: add widget", Timestamp: now.Add(-4 * time.Hour)},
{Hash: "c4", Message: "docs: update readme", Timestamp: now.Add(-3 * time.Hour)},
{Hash: "c5", Message: "breaking: change api contract", Timestamp: now.Add(-2 * time.Hour)},
{Hash: "c6", Message: "fix: cleanup skip-ci", Timestamp: now.Add(-1 * time.Hour)},
}
matcher, err := NewKeywordMatcher(MatchingFuzzy, wording)
assert.NoError(t, err)
got := CalculateSemver(
commits,
nil,
wording,
matcher,
blacklist,
SemVer{},
false,
false, // non-strict: default patch increment applies too
nil,
)
assert.Equal(t, 1, got.Major, "Major version mismatch")
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
assert.Equal(t, 2, got.Patch, "Patch version mismatch")
assert.Equal(t, 0, got.Release, "Release version mismatch")
assert.False(t, got.EnableReleaseCandidate)
}
+23 -10
View File
@@ -166,6 +166,27 @@ func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string
return semanticVersion
}
// isBlacklisted reports whether contentStr contains any blacklist term
// (case-insensitive substring match), logging the same way every caller used
// to log it inline. Both matching modes call this so blacklist semantics stay
// identical between fuzzy and regex matching.
func isBlacklisted(contentStr string, blacklist []string) bool {
if len(blacklist) == 0 {
return false
}
lowerContent := strings.ToLower(contentStr)
for _, blacklistTerm := range blacklist {
if strings.Contains(lowerContent, strings.ToLower(blacklistTerm)) {
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
"content": contentStr,
"blacklist_term": blacklistTerm,
})
return true
}
}
return false
}
// CheckMatches checks if any of the targets match the content
func CheckMatches(content []string, targets []string, blacklist []string) bool {
contentStr := strings.Join(content, " ")
@@ -186,16 +207,8 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
}
// If we have a match, check against blacklist
if hasMatch && len(blacklist) > 0 {
for _, blacklistTerm := range blacklist {
if strings.Contains(strings.ToLower(contentStr), strings.ToLower(blacklistTerm)) {
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
"content": contentStr,
"blacklist_term": blacklistTerm,
})
return false
}
}
if hasMatch && isBlacklisted(contentStr, blacklist) {
return false
}
return hasMatch
+3 -6
View File
@@ -10,7 +10,7 @@ require (
github.com/lukaszraczylo/pandati v0.0.29
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
)
require (
@@ -19,7 +19,6 @@ require (
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/cloudflare/circl v1.6.5 // indirect
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -35,11 +34,10 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/skeema/knownhosts v1.3.3 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
@@ -52,9 +50,8 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+6 -10
View File
@@ -19,8 +19,6 @@ github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooY
github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -87,8 +85,6 @@ github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -100,8 +96,8 @@ github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9t
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/skeema/knownhosts v1.3.3 h1:tiFvsjIW+xqP5cqVsod7atn/xm57OY6v9KwxB1xWUyA=
github.com/skeema/knownhosts v1.3.3/go.mod h1:2y0ntYxeqSNxOw5pfqSVDcRyBt2FqfxhzNDPoKz/ifQ=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -116,8 +112,8 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -152,8 +148,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=