diff --git a/pkg/workflow/compiler_filters_validation.go b/pkg/workflow/compiler_filters_validation.go index 737540c9390..99072da193a 100644 --- a/pkg/workflow/compiler_filters_validation.go +++ b/pkg/workflow/compiler_filters_validation.go @@ -89,12 +89,12 @@ func ValidateEventFilters(frontmatter map[string]any) error { } // ValidatePushBranchScope ensures that any push event in the on: section specifies a -// branches or branches-ignore filter. An unscoped push trigger fires on every push to -// every branch, which causes unintended workflow fan-out on feature branches (the -// workflows activate immediately after new lock files are first pushed to the branch, -// producing zero-turn failures for every agentic workflow in the repository). +// branch or tag ref filter. An unscoped push trigger fires on every push to every +// branch and tag, which causes unintended workflow fan-out on feature branches (the workflows +// activate immediately after new lock files are first pushed to the branch, producing +// zero-turn failures for every agentic workflow in the repository). func ValidatePushBranchScope(frontmatter map[string]any) error { - filterValidationLog.Print("Validating push event branch scope") + filterValidationLog.Print("Validating push event branch/tag scope") on, exists := frontmatter["on"] if !exists { @@ -113,7 +113,7 @@ func ValidatePushBranchScope(frontmatter map[string]any) error { // A nil push value (bare `push:` key with no sub-keys) is unscoped. if pushVal == nil { - filterValidationLog.Print("ERROR: push event has no branch scope (nil push value)") + filterValidationLog.Print("ERROR: push event has no branch/tag scope (nil push value)") return newUnScopedPushError() } @@ -140,9 +140,9 @@ func ValidatePushBranchScope(frontmatter map[string]any) error { func newUnScopedPushError() *WorkflowValidationError { return NewValidationError( "on.push", - "push (no branch filter)", - "push event must specify a 'branches', 'branches-ignore', 'tags', or 'tags-ignore' filter; an unscoped push trigger fires on every push to every branch and causes unintended workflow fan-out on feature branches", - "Add a branch or tag filter to the push trigger:\n\non:\n push:\n branches:\n - main", + "push (no branch or tag filter)", + "push event must specify a 'branches', 'branches-ignore', 'tags', or 'tags-ignore' filter; an unscoped push trigger fires on every push to every branch and tag and causes unintended workflow fan-out on feature branches", + "Add a branch or tag filter to the push trigger:\n\non:\n push:\n branches:\n - main\n\n# or for tag-based releases:\n\non:\n push:\n tags:\n - 'v*.*.*'", ) } diff --git a/pkg/workflow/compiler_filters_validation_test.go b/pkg/workflow/compiler_filters_validation_test.go index b83c96422c2..631fc016acd 100644 --- a/pkg/workflow/compiler_filters_validation_test.go +++ b/pkg/workflow/compiler_filters_validation_test.go @@ -384,6 +384,28 @@ func TestValidatePushBranchScope(t *testing.T) { }, wantErr: false, }, + { + name: "push with tags filter", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "tags": []string{"v*.*.*"}, + }, + }, + }, + wantErr: false, + }, + { + name: "push with tags-ignore filter", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "tags-ignore": []string{"nightly-*"}, + }, + }, + }, + wantErr: false, + }, { name: "push with branches and paths filter", frontmatter: map[string]any{ diff --git a/pkg/workflow/compiler_orchestrator_frontmatter.go b/pkg/workflow/compiler_orchestrator_frontmatter.go index 3407e953398..5fe77deb281 100644 --- a/pkg/workflow/compiler_orchestrator_frontmatter.go +++ b/pkg/workflow/compiler_orchestrator_frontmatter.go @@ -203,14 +203,14 @@ func (c *Compiler) parseFrontmatterSection(markdownPath string) (*frontmatterPar return nil, err } - // Validate that push triggers are scoped to specific branches to prevent fan-out. + // Validate that push triggers are scoped to specific branches or tags to prevent fan-out. // In strict mode this is an error; in non-strict mode it is downgraded to a warning. if err := ValidatePushBranchScope(frontmatterForValidation); err != nil { if c.effectiveStrictMode(frontmatterForValidation) { - orchestratorFrontmatterLog.Printf("Push branch scope validation failed: %v", err) + orchestratorFrontmatterLog.Printf("Push branch/tag scope validation failed: %v", err) return nil, err } - orchestratorFrontmatterLog.Printf("Push branch scope warning (non-strict mode): %v", err) + orchestratorFrontmatterLog.Printf("Push branch/tag scope warning (non-strict mode): %v", err) fmt.Fprintln(os.Stderr, console.FormatWarningMessage(err.Error())) c.IncrementWarningCount() } diff --git a/pkg/workflow/push_branch_scope_test.go b/pkg/workflow/push_branch_scope_test.go index f7aec51e15f..28d29ab71e9 100644 --- a/pkg/workflow/push_branch_scope_test.go +++ b/pkg/workflow/push_branch_scope_test.go @@ -14,7 +14,7 @@ import ( ) // TestPushBranchScopeStrictVsNonStrict verifies that an unscoped push trigger -// (missing branches/branches-ignore) is an error in strict mode and a warning in non-strict mode. +// (missing branch/tag ref filters) is an error in strict mode and a warning in non-strict mode. func TestPushBranchScopeStrictVsNonStrict(t *testing.T) { tests := []struct { name string @@ -65,6 +65,21 @@ on: - main --- +# Test Workflow +`, + expectError: false, + expectWarning: false, + }, + { + name: "scoped push with tags in strict mode is allowed", + content: `--- +name: Tag Scoped Push Strict +on: + push: + tags: + - 'v*.*.*' +--- + # Test Workflow `, expectError: false,