From 8d334a4c367d3ddb9690b7226d0033c0c0ed9cb0 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Wed, 18 Mar 2026 13:02:30 +0000 Subject: [PATCH] workflow: add paths filter and ChangedFiles support --- workflow/compile.go | 7 ++++--- workflow/compile_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ workflow/def.go | 30 +++++++++++++++++++++++++++--- workflow/def_test.go | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 file(s) changed, 271 insertion(s)(+), 6 deletion(s)(-) diff --git a/workflow/compile.go b/workflow/compile.go --- a/workflow/compile.go +++ b/workflow/compile.go @@ -15,8 +15,9 @@ type RawPipeline = []RawWorkflow type Compiler struct { - Trigger tangled.Pipeline_TriggerMetadata - Diagnostics Diagnostics + Trigger tangled.Pipeline_TriggerMetadata + ChangedFiles []string + Diagnostics Diagnostics } type Diagnostics struct { @@ -113,7 +114,7 @@ func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { cw := &tangled.Pipeline_Workflow{} - matched, err := w.Match(compiler.Trigger) + matched, err := w.Match(compiler.Trigger, compiler.ChangedFiles) if err != nil { compiler.Diagnostics.AddError( w.Name, diff --git a/workflow/compile_test.go b/workflow/compile_test.go --- a/workflow/compile_test.go +++ b/workflow/compile_test.go @@ -96,6 +96,71 @@ assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) } +func TestCompileWorkflow_ChangedFilesMatchesPaths(t *testing.T) { + wf := Workflow{ + Name: ".tangled/workflows/test.yml", + Engine: "nixery", + When: []Constraint{ + { + Event: []string{"push"}, + Branch: []string{"main"}, + Paths: []string{"src/**"}, + }, + }, + } + + c := Compiler{ + Trigger: trigger, + ChangedFiles: []string{"src/main.go", "src/util.go"}, + } + cp := c.Compile([]Workflow{wf}) + + assert.Len(t, cp.Workflows, 1) + assert.Equal(t, wf.Name, cp.Workflows[0].Name) + assert.False(t, c.Diagnostics.IsErr()) +} + +func TestCompileWorkflow_ChangedFilesNoMatch(t *testing.T) { + wf := Workflow{ + Name: ".tangled/workflows/test.yml", + Engine: "nixery", + When: []Constraint{ + { + Event: []string{"push"}, + Branch: []string{"main"}, + Paths: []string{"src/**"}, + }, + }, + } + + c := Compiler{ + Trigger: trigger, + ChangedFiles: []string{"docs/guide.md", "README.md"}, + } + cp := c.Compile([]Workflow{wf}) + + assert.Len(t, cp.Workflows, 0) + assert.Len(t, c.Diagnostics.Warnings, 1) + assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type) +} + +func TestCompileWorkflow_NoPaths_ChangedFilesIgnored(t *testing.T) { + wf := Workflow{ + Name: ".tangled/workflows/test.yml", + Engine: "nixery", + When: when, // no Paths constraint + } + + c := Compiler{ + Trigger: trigger, + ChangedFiles: []string{"docs/guide.md"}, + } + cp := c.Compile([]Workflow{wf}) + + assert.Len(t, cp.Workflows, 1) + assert.False(t, c.Diagnostics.IsErr()) +} + func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) { wf := Workflow{ Name: ".tangled/workflows/branch_and_tag.yml", diff --git a/workflow/def.go b/workflow/def.go --- a/workflow/def.go +++ b/workflow/def.go @@ -36,6 +36,7 @@ Event StringList `yaml:"event"` 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 + Paths StringList `yaml:"paths"` // optional; only run if any changed file matches a glob pattern } CloneOpts struct { @@ -93,7 +94,7 @@ } // if any of the constraints on a workflow is true, return true -func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { +func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) { // manual triggers always run the workflow if trigger.Manual != nil { return true, nil @@ -101,7 +102,7 @@ // if not manual, run through the constraint list and see if any one matches for _, c := range w.When { - matched, err := c.Match(trigger) + matched, err := c.Match(trigger, changedFiles) if err != nil { return false, err } @@ -118,7 +119,7 @@ return false, nil } -func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { +func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) { match := true // manual triggers always pass this constraint @@ -147,7 +148,30 @@ match = match && matched } + // apply paths filter: if specified, at least one changed file must match + if len(c.Paths) > 0 { + matched, err := matchesAnyFile(changedFiles, c.Paths) + if err != nil { + return false, err + } + match = match && matched + } + return match, nil +} + +// matchesAnyFile returns true if any file in files matches any of the glob patterns. +func matchesAnyFile(files []string, patterns []string) (bool, error) { + for _, f := range files { + matched, err := matchesPattern(f, patterns) + if err != nil { + return false, err + } + if matched { + return true, nil + } + } + return false, nil } func (c *Constraint) MatchRef(ref string) (bool, error) { diff --git a/workflow/def_test.go b/workflow/def_test.go --- a/workflow/def_test.go +++ b/workflow/def_test.go @@ -4,6 +4,7 @@ "testing" "github.com/stretchr/testify/assert" + "tangled.org/core/api/tangled" ) func TestUnmarshalWorkflowWithBranch(t *testing.T) { @@ -273,6 +274,180 @@ assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch) }) } +} + +func TestMatchesAnyFile(t *testing.T) { + tests := []struct { + name string + files []string + patterns []string + expected bool + }{ + { + name: "exact file match", + files: []string{"src/main.go"}, + patterns: []string{"src/main.go"}, + expected: true, + }, + { + name: "glob match single star", + files: []string{"src/main.go"}, + patterns: []string{"src/*.go"}, + expected: true, + }, + { + name: "glob match double star", + files: []string{"src/pkg/util.go"}, + patterns: []string{"src/**/*.go"}, + expected: true, + }, + { + name: "any file in list matches", + files: []string{"README.md", "src/main.go", "docs/guide.md"}, + patterns: []string{"src/**"}, + expected: true, + }, + { + name: "no file matches", + files: []string{"README.md", "docs/guide.md"}, + patterns: []string{"src/**"}, + expected: false, + }, + { + name: "empty files list", + files: []string{}, + patterns: []string{"src/**"}, + expected: false, + }, + { + name: "nil files list", + files: nil, + patterns: []string{"src/**"}, + expected: false, + }, + { + name: "multiple patterns, second matches", + files: []string{"docs/guide.md"}, + patterns: []string{"src/**", "docs/**"}, + expected: true, + }, + { + name: "single star does not cross directory boundary", + files: []string{"src/pkg/util.go"}, + patterns: []string{"src/*.go"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := matchesAnyFile(tt.files, tt.patterns) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestConstraintMatch_PathsFilter(t *testing.T) { + pushTrigger := tangled.Pipeline_TriggerMetadata{ + Kind: string(TriggerKindPush), + Push: &tangled.Pipeline_PushTriggerData{ + Ref: "refs/heads/main", + }, + } + + tests := []struct { + name string + constraint Constraint + changedFiles []string + expected bool + }{ + { + name: "paths match - workflow runs", + constraint: Constraint{ + Event: []string{"push"}, + Branch: []string{"main"}, + Paths: []string{"src/**"}, + }, + changedFiles: []string{"src/main.go"}, + expected: true, + }, + { + name: "paths no match - workflow skipped", + constraint: Constraint{ + Event: []string{"push"}, + Branch: []string{"main"}, + Paths: []string{"src/**"}, + }, + changedFiles: []string{"docs/guide.md"}, + expected: false, + }, + { + name: "no paths filter - all files pass", + constraint: Constraint{ + Event: []string{"push"}, + Branch: []string{"main"}, + }, + changedFiles: []string{"docs/guide.md"}, + expected: true, + }, + { + name: "paths filter with empty changed files - skipped", + constraint: Constraint{ + Event: []string{"push"}, + Branch: []string{"main"}, + Paths: []string{"src/**"}, + }, + changedFiles: []string{}, + expected: false, + }, + { + name: "paths glob matches one of many changed files", + constraint: Constraint{ + Event: []string{"push"}, + Branch: []string{"main"}, + Paths: []string{"**/*.go"}, + }, + changedFiles: []string{"README.md", "go.mod", "src/main.go"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.constraint.Match(pushTrigger, tt.changedFiles) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestUnmarshalWorkflowWithPaths(t *testing.T) { + yamlData := ` +when: + - event: push + branch: main + paths: + - "src/**" + - "**.go"` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err) + assert.Len(t, wf.When, 1) + assert.ElementsMatch(t, []string{"src/**", "**.go"}, wf.When[0].Paths) +} + +func TestUnmarshalWorkflowWithPathsSingleString(t *testing.T) { + yamlData := ` +when: + - event: push + branch: main + paths: "src/**"` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err) + assert.Len(t, wf.When, 1) + assert.ElementsMatch(t, []string{"src/**"}, wf.When[0].Paths) } func TestConstraintMatchTag_GlobPatterns(t *testing.T) { -- tangled.sh