From f24b4c2c8af37efe7482fca2d2a34b9958aa3e6b Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 2 Nov 2025 21:36:22 -0600 Subject: [PATCH] workflow: implement glob pattern matching for tags and branches Change-Id: smlxzowxouqmyvskwwotppzrqsxxxrts Signed-off-by: Evan Jarrett Signed-off-by: Akshay Oppiliappan Co-authored-by: Evan Jarrett --- docs/spindle/pipeline.md | 20 ++- go.mod | 3 +- go.sum | 4 + nix/gomod2nix.toml | 7 +- workflow/compile.go | 10 +- workflow/compile_test.go | 125 +++++++++++++++++ workflow/def.go | 80 ++++++++--- workflow/def_test.go | 285 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 509 insertions(+), 25 deletions(-) diff --git a/docs/spindle/pipeline.md b/docs/spindle/pipeline.md index ee87062d..ec9c56d4 100644 --- a/docs/spindle/pipeline.md +++ b/docs/spindle/pipeline.md @@ -19,7 +19,8 @@ The first thing to add to a workflow is the trigger, which defines when a workfl - `push`: The workflow should run every time a commit is pushed to the repository. - `pull_request`: The workflow should run every time a pull request is made or updated. - `manual`: The workflow can be triggered manually. -- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. +- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. +- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: @@ -31,6 +32,23 @@ when: branch: ["main"] ``` +You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: + +```yaml +when: + - event: ["push"] + tag: ["v*"] +``` + +You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): + +```yaml +when: + - event: ["push"] + branch: ["main", "release-*"] + tag: ["v*", "stable"] +``` + ## Engine Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: diff --git a/go.mod b/go.mod index 54860c31..edecbe8c 100644 --- a/go.mod +++ b/go.mod @@ -83,7 +83,8 @@ require ( github.com/blevesearch/zapx/v14 v14.4.2 // indirect github.com/blevesearch/zapx/v15 v15.4.2 // indirect github.com/blevesearch/zapx/v16 v16.2.4 // indirect - github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect + github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index e88e0602..777ab5ba 100644 --- a/go.sum +++ b/go.sum @@ -70,9 +70,13 @@ github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml index a0c4b07b..1ad71d71 100644 --- a/nix/gomod2nix.toml +++ b/nix/gomod2nix.toml @@ -108,9 +108,12 @@ schema = 3 [mod."github.com/bluesky-social/jetstream"] version = "v0.0.0-20241210005130-ea96859b93d1" hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" + [mod."github.com/bmatcuk/doublestar"] + version = "v1.3.4" + hash = "sha256-QcHL9WGVAH3vIs4FZH+w1DJxdCHnXkkzODtOfhKR0X0=" [mod."github.com/bmatcuk/doublestar/v4"] - version = "v4.7.1" - hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" + version = "v4.9.1" + hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" [mod."github.com/carlmjohnson/versioninfo"] version = "v0.22.5" hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" diff --git a/workflow/compile.go b/workflow/compile.go index aba5b434..d5d7ef38 100644 --- a/workflow/compile.go +++ b/workflow/compile.go @@ -113,7 +113,15 @@ func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { cw := &tangled.Pipeline_Workflow{} - if !w.Match(compiler.Trigger) { + matched, err := w.Match(compiler.Trigger) + if err != nil { + compiler.Diagnostics.AddError( + w.Name, + fmt.Errorf("failed to execute workflow: %w", err), + ) + return nil + } + if !matched { compiler.Diagnostics.AddWarning( w.Name, WorkflowSkipped, diff --git a/workflow/compile_test.go b/workflow/compile_test.go index e24a8c6a..ad5974a2 100644 --- a/workflow/compile_test.go +++ b/workflow/compile_test.go @@ -95,3 +95,128 @@ func TestCompileWorkflow_MissingEngine(t *testing.T) { assert.Len(t, c.Diagnostics.Errors, 1) assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) } + +func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) { + wf := Workflow{ + Name: ".tangled/workflows/branch_and_tag.yml", + When: []Constraint{ + { + Event: []string{"push"}, + Branch: []string{"main", "develop"}, + Tag: []string{"v*"}, + }, + }, + Engine: "nixery", + } + + tests := []struct { + name string + trigger tangled.Pipeline_TriggerMetadata + shouldMatch bool + expectedCount int + }{ + { + name: "matches main branch", + trigger: tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/heads/main", + OldSha: strings.Repeat("0", 40), + NewSha: strings.Repeat("f", 40), + }, + }, + shouldMatch: true, + expectedCount: 1, + }, + { + name: "matches develop branch", + trigger: tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/heads/develop", + OldSha: strings.Repeat("0", 40), + NewSha: strings.Repeat("f", 40), + }, + }, + shouldMatch: true, + expectedCount: 1, + }, + { + name: "matches v* tag pattern", + trigger: tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/tags/v1.0.0", + OldSha: strings.Repeat("0", 40), + NewSha: strings.Repeat("f", 40), + }, + }, + shouldMatch: true, + expectedCount: 1, + }, + { + name: "matches v* tag pattern with different version", + trigger: tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/tags/v2.5.3", + OldSha: strings.Repeat("0", 40), + NewSha: strings.Repeat("f", 40), + }, + }, + shouldMatch: true, + expectedCount: 1, + }, + { + name: "does not match master branch", + trigger: tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/heads/master", + OldSha: strings.Repeat("0", 40), + NewSha: strings.Repeat("f", 40), + }, + }, + shouldMatch: false, + expectedCount: 0, + }, + { + name: "does not match non-v tag", + trigger: tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/tags/release-1.0", + OldSha: strings.Repeat("0", 40), + NewSha: strings.Repeat("f", 40), + }, + }, + shouldMatch: false, + expectedCount: 0, + }, + { + name: "does not match feature branch", + trigger: tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/heads/feature/new-feature", + OldSha: strings.Repeat("0", 40), + NewSha: strings.Repeat("f", 40), + }, + }, + shouldMatch: false, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Compiler{Trigger: tt.trigger} + cp := c.Compile([]Workflow{wf}) + + assert.Len(t, cp.Workflows, tt.expectedCount) + if tt.shouldMatch { + assert.Equal(t, wf.Name, cp.Workflows[0].Name) + } + }) + } +} diff --git a/workflow/def.go b/workflow/def.go index 2660b8fb..2687e801 100644 --- a/workflow/def.go +++ b/workflow/def.go @@ -8,6 +8,7 @@ import ( "tangled.org/core/api/tangled" + "github.com/bmatcuk/doublestar" "github.com/go-git/go-git/v5/plumbing" "gopkg.in/yaml.v3" ) @@ -33,7 +34,8 @@ type ( Constraint struct { Event StringList `yaml:"event"` - Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events + Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified + Tag StringList `yaml:"tag"` // optional; only applies to push events } CloneOpts struct { @@ -59,6 +61,23 @@ func (t TriggerKind) String() string { return strings.ReplaceAll(string(t), "_", " ") } +// matchesPattern checks if a name matches any of the given patterns. +// Patterns can be exact matches or glob patterns using * and **. +// * matches any sequence of non-separator characters +// ** matches any sequence of characters including separators +func matchesPattern(name string, patterns []string) (bool, error) { + for _, pattern := range patterns { + matched, err := doublestar.Match(pattern, name) + if err != nil { + return false, err + } + if matched { + return true, nil + } + } + return false, nil +} + func FromFile(name string, contents []byte) (Workflow, error) { var wf Workflow @@ -74,33 +93,37 @@ func FromFile(name string, contents []byte) (Workflow, error) { } // if any of the constraints on a workflow is true, return true -func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { +func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { // manual triggers always run the workflow if trigger.Manual != nil { - return true + return true, nil } // if not manual, run through the constraint list and see if any one matches for _, c := range w.When { - if c.Match(trigger) { - return true + matched, err := c.Match(trigger) + if err != nil { + return false, err + } + if matched { + return true, nil } } // no constraints, always run this workflow if len(w.When) == 0 { - return true + return true, nil } - return false + return false, nil } -func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { +func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { match := true // manual triggers always pass this constraint if trigger.Manual != nil { - return true + return true, nil } // apply event constraints @@ -108,27 +131,46 @@ func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { // apply branch constraints for PRs if trigger.PullRequest != nil { - match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) + matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch) + if err != nil { + return false, err + } + match = match && matched } // apply ref constraints for pushes if trigger.Push != nil { - match = match && c.MatchRef(trigger.Push.Ref) + matched, err := c.MatchRef(trigger.Push.Ref) + if err != nil { + return false, err + } + match = match && matched } - return match -} - -func (c *Constraint) MatchBranch(branch string) bool { - return slices.Contains(c.Branch, branch) + return match, nil } -func (c *Constraint) MatchRef(ref string) bool { +func (c *Constraint) MatchRef(ref string) (bool, error) { refName := plumbing.ReferenceName(ref) + shortName := refName.Short() + if refName.IsBranch() { - return slices.Contains(c.Branch, refName.Short()) + return c.MatchBranch(shortName) } - return false + + if refName.IsTag() { + return c.MatchTag(shortName) + } + + return false, nil +} + +func (c *Constraint) MatchBranch(branch string) (bool, error) { + return matchesPattern(branch, c.Branch) +} + +func (c *Constraint) MatchTag(tag string) (bool, error) { + return matchesPattern(tag, c.Tag) } func (c *Constraint) MatchEvent(event string) bool { diff --git a/workflow/def_test.go b/workflow/def_test.go index 6d9fe6b5..12464445 100644 --- a/workflow/def_test.go +++ b/workflow/def_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestUnmarshalWorkflow(t *testing.T) { +func TestUnmarshalWorkflowWithBranch(t *testing.T) { yamlData := ` when: - event: ["push", "pull_request"] @@ -38,3 +38,286 @@ clone: assert.True(t, wf.CloneOpts.Skip, "Skip should be false") } + +func TestUnmarshalWorkflowWithTags(t *testing.T) { + yamlData := ` +when: + - event: ["push"] + tag: ["v*", "release-*"]` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err, "YAML should unmarshal without error") + + assert.Len(t, wf.When, 1, "Should have one constraint") + assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag) + assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) +} + +func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) { + yamlData := ` +when: + - event: ["push"] + branch: ["main", "develop"] + tag: ["v*"]` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err, "YAML should unmarshal without error") + + assert.Len(t, wf.When, 1, "Should have one constraint") + assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) + assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) +} + +func TestMatchesPattern(t *testing.T) { + tests := []struct { + name string + input string + patterns []string + expected bool + }{ + {"exact match", "main", []string{"main"}, true}, + {"exact match in list", "develop", []string{"main", "develop"}, true}, + {"no match", "feature", []string{"main", "develop"}, false}, + {"wildcard prefix", "v1.0.0", []string{"v*"}, true}, + {"wildcard suffix", "release-1.0", []string{"*-1.0"}, true}, + {"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true}, + {"double star prefix", "release-1.0.0", []string{"release-**"}, true}, + {"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true}, + {"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true}, + {"double star no match", "feature/test", []string{"release/**"}, false}, + {"no patterns matches nothing", "anything", []string{}, false}, + {"pattern doesn't match", "v1.0.0", []string{"release-*"}, false}, + {"complex pattern", "release/v1.2.3", []string{"release/*"}, true}, + {"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := matchesPattern(tt.input, tt.patterns) + assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected) + }) + } +} + +func TestConstraintMatchRef_Branches(t *testing.T) { + tests := []struct { + name string + constraint Constraint + ref string + expected bool + }{ + { + name: "exact branch match", + constraint: Constraint{Branch: []string{"main"}}, + ref: "refs/heads/main", + expected: true, + }, + { + name: "branch glob match", + constraint: Constraint{Branch: []string{"feature-*"}}, + ref: "refs/heads/feature-123", + expected: true, + }, + { + name: "branch no match", + constraint: Constraint{Branch: []string{"main"}}, + ref: "refs/heads/develop", + expected: false, + }, + { + name: "no constraints matches nothing", + constraint: Constraint{}, + ref: "refs/heads/anything", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := tt.constraint.MatchRef(tt.ref) + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) + }) + } +} + +func TestConstraintMatchRef_Tags(t *testing.T) { + tests := []struct { + name string + constraint Constraint + ref string + expected bool + }{ + { + name: "exact tag match", + constraint: Constraint{Tag: []string{"v1.0.0"}}, + ref: "refs/tags/v1.0.0", + expected: true, + }, + { + name: "tag glob match", + constraint: Constraint{Tag: []string{"v*"}}, + ref: "refs/tags/v1.2.3", + expected: true, + }, + { + name: "tag glob with pattern", + constraint: Constraint{Tag: []string{"release-*"}}, + ref: "refs/tags/release-2024", + expected: true, + }, + { + name: "tag no match", + constraint: Constraint{Tag: []string{"v*"}}, + ref: "refs/tags/release-1.0", + expected: false, + }, + { + name: "tag not matched when only branch constraint", + constraint: Constraint{Branch: []string{"main"}}, + ref: "refs/tags/v1.0.0", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := tt.constraint.MatchRef(tt.ref) + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) + }) + } +} + +func TestConstraintMatchRef_Combined(t *testing.T) { + tests := []struct { + name string + constraint Constraint + ref string + expected bool + }{ + { + name: "matches branch in combined constraint", + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, + ref: "refs/heads/main", + expected: true, + }, + { + name: "matches tag in combined constraint", + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, + ref: "refs/tags/v1.0.0", + expected: true, + }, + { + name: "no match in combined constraint", + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, + ref: "refs/heads/develop", + expected: false, + }, + { + name: "glob patterns in combined constraint - branch", + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, + ref: "refs/heads/release-2024", + expected: true, + }, + { + name: "glob patterns in combined constraint - tag", + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, + ref: "refs/tags/v2.0.0", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := tt.constraint.MatchRef(tt.ref) + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) + }) + } +} + +func TestConstraintMatchBranch_GlobPatterns(t *testing.T) { + tests := []struct { + name string + constraint Constraint + branch string + expected bool + }{ + { + name: "exact match", + constraint: Constraint{Branch: []string{"main"}}, + branch: "main", + expected: true, + }, + { + name: "glob match", + constraint: Constraint{Branch: []string{"feature-*"}}, + branch: "feature-123", + expected: true, + }, + { + name: "no match", + constraint: Constraint{Branch: []string{"main"}}, + branch: "develop", + expected: false, + }, + { + name: "multiple patterns with match", + constraint: Constraint{Branch: []string{"main", "release-*"}}, + branch: "release-1.0", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := tt.constraint.MatchBranch(tt.branch) + assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch) + }) + } +} + +func TestConstraintMatchTag_GlobPatterns(t *testing.T) { + tests := []struct { + name string + constraint Constraint + tag string + expected bool + }{ + { + name: "exact match", + constraint: Constraint{Tag: []string{"v1.0.0"}}, + tag: "v1.0.0", + expected: true, + }, + { + name: "glob match", + constraint: Constraint{Tag: []string{"v*"}}, + tag: "v2.3.4", + expected: true, + }, + { + name: "no match", + constraint: Constraint{Tag: []string{"v*"}}, + tag: "release-1.0", + expected: false, + }, + { + name: "multiple patterns with match", + constraint: Constraint{Tag: []string{"v*", "release-*"}}, + tag: "release-2024", + expected: true, + }, + { + name: "empty tag list matches nothing", + constraint: Constraint{Tag: []string{}}, + tag: "v1.0.0", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _ := tt.constraint.MatchTag(tt.tag) + assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag) + }) + } +} -- 2.43.0