diff --git a/pkg/model/planner.go b/pkg/model/planner.go index 1769b73212e..65d58198850 100644 --- a/pkg/model/planner.go +++ b/pkg/model/planner.go @@ -184,6 +184,8 @@ func (wp *workflowPlanner) PlanEvent(eventName string) (*Plan, error) { continue } + //TODO: filter out workflow using ShouldFilterWorkflow() - we will need to provide the GitHub/Event context + for _, e := range events { if e == eventName { stages, err := createStages(w, w.GetJobIDs()...) @@ -208,6 +210,9 @@ func (wp *workflowPlanner) PlanJob(jobName string) (*Plan, error) { var lastErr error for _, w := range wp.workflows { + //TODO: do we filter out workflows here? We want to run a specific job, not the full workflow - so should + // the workflow-level filters (e.g. "tags", "branches-ignore") still apply? + stages, err := createStages(w, jobName) if err != nil { log.Warn(err) @@ -229,6 +234,8 @@ func (wp *workflowPlanner) PlanAll() (*Plan, error) { var lastErr error for _, w := range wp.workflows { + //TODO: filter out workflow using ShouldFilterWorkflow() - we will need to provide the GitHub/Event context + stages, err := createStages(w, w.GetJobIDs()...) if err != nil { log.Warn(err) diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go index 4fd9be998a3..fad237918f7 100644 --- a/pkg/model/workflow.go +++ b/pkg/model/workflow.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "github.com/nektos/act/pkg/workflowpattern" "io" "reflect" "regexp" @@ -23,6 +24,17 @@ type Workflow struct { Defaults Defaults `yaml:"defaults"` } +// FilterPatterns is a structure that contains filter patterns that were parsed from the On attribute in an event +// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions +type FilterPatterns struct { + Branches []string + BranchesIgnore []string + Paths []string + PathsIgnore []string + Tags []string + TagsIgnore []string +} + // On events for the workflow func (w *Workflow) On() []string { switch w.RawOn.Kind { @@ -55,6 +67,133 @@ func (w *Workflow) On() []string { return nil } +//FindFilterPatterns searches for filter patterns relating to the specified eventName (e.g. "pull_request", or "push") +//in the RawOn attribute. This will error out if there are filters that can't be used simultaneously for the same event +//(e.g. "paths" and "paths-ignore") +func (w *Workflow) FindFilterPatterns(eventName string) *FilterPatterns { + //Return immediately if the event type doesn't support filters + if eventName != "push" && eventName != "pull_request" { + return nil + } + + //If it isn't a mapping node, then the following traversal can't be performed + if w.RawOn.Kind != yaml.MappingNode { + return nil + } + + //Decode rawOn to a map of string=>interfaces + var topLevelMap map[string]interface{} + err := w.RawOn.Decode(&topLevelMap) + if err != nil { + log.Fatal(err) + } + + output := &FilterPatterns{} + + //topLevelMapKey correlates to the event type - e.g. "push" or "pull_request" + for topLevelMapKey, topLevelMapVal := range topLevelMap { + + //Skip to the next iteration if this isn't the event that we're looking for. + if topLevelMapKey != eventName { + continue + } + + if midLevelMap, ok := topLevelMapVal.(map[string]interface{}); ok { + //midLevelMapKey correlates to the filter type - e.g. "branches", "tags", or paths + for midLevelMapKey, midLevelMapVal := range midLevelMap { + if lowLevelMapVal, ok := midLevelMapVal.([]interface{}); ok { + //Leaf correlates to the actual filter pattern + for _, leaf := range lowLevelMapVal { + if leafString := leaf.(string); ok { + switch midLevelMapKey { + case "branches": + output.Branches = append(output.Branches, leafString) + case "branches-ignore": + output.BranchesIgnore = append(output.BranchesIgnore, leafString) + case "paths": + output.Paths = append(output.Paths, leafString) + case "paths-ignore": + output.PathsIgnore = append(output.PathsIgnore, leafString) + case "tags": + output.Tags = append(output.Tags, leafString) + case "tags-ignore": + output.TagsIgnore = append(output.TagsIgnore, leafString) + } + } + } + } + } + } + } + + //TODO: Should these all be migrated over to return an error (it would facilitate better testing) - see this PR: + // https://github.com/nektos/act/pull/1705 + if len(output.Branches) > 0 && len(output.BranchesIgnore) > 0 { + log.Fatal("branches and branches-ignore were both specified for the event, but they can't be used simultaneously") + } + + if len(output.Paths) > 0 && len(output.PathsIgnore) > 0 { + log.Fatal("paths and paths-ignore were both specified for the event, but they can't be used simultaneously") + } + + if len(output.Tags) > 0 && len(output.TagsIgnore) > 0 { + log.Fatal("tags and tags-ignore were both specified for the event, but they can't be used simultaneously") + } + + return output +} + +//ShouldFilterWorkflow finds any filters relating to eventName in the Workflow definition (e.g. if the eventName is +//"pull_request", there may be a set of "branches" patterns). It then uses the found filters to determine whether the +//workflow should be skipped based on the data in the eventPayload. +func (w *Workflow) ShouldFilterWorkflow(eventName string, ghc GithubContext) bool { + //Find all filter patterns that relate to the input event + filterPatterns := w.FindFilterPatterns(eventName) + + if filterPatterns == nil { + return false + } + + tw := new(workflowpattern.StdOutTraceWriter) + + //Function to build the relevant regex patterns and compare segments of the event payload against them + eventCheckFunc := func(filterFunc workflowpattern.FilterInputsFunc, patterns []string, inputs []string) bool { + regexFilters, err := workflowpattern.CompilePatterns(patterns...) + + if err != nil { + log.Fatalf("Failed to convert filter patterns to regex for '%s' workflow: %v", w.File, err) + } + + if filterFunc(regexFilters, inputs, tw) { + return true + } + + return false + } + + //Iterate over the patterns that we call Skip() for (the non *ignore attributes from FilterPatterns) + for _, fp := range [][]string{filterPatterns.Branches, filterPatterns.Paths, filterPatterns.Tags} { + if len(fp) > 0 { + //TODO: pass the relevant data instead of a slice of string - this depends on the GithubContext/event context + if shouldSkip := eventCheckFunc(workflowpattern.Skip, fp, []string{}); shouldSkip { + return true + } + } + } + + //Iterate over the *Ignore attributes from the FilterPatterns struct + for _, fp := range [][]string{filterPatterns.BranchesIgnore, filterPatterns.PathsIgnore, filterPatterns.TagsIgnore} { + if len(fp) > 0 { + //TODO: pass the relevant data instead of a slice of string - this depends on the GithubContext/event context + if shouldFilter := eventCheckFunc(workflowpattern.Filter, fp, []string{}); shouldFilter { + return true + } + } + } + + return false +} + func (w *Workflow) OnEvent(event string) interface{} { if w.RawOn.Kind == yaml.MappingNode { var val map[string]interface{} diff --git a/pkg/model/workflow_test.go b/pkg/model/workflow_test.go index 292c0bfffba..655c045c99d 100644 --- a/pkg/model/workflow_test.go +++ b/pkg/model/workflow_test.go @@ -71,6 +71,403 @@ jobs: assert.Contains(t, workflow.On(), "pull_request") } +func TestGetWorkflowFilterStrings(t *testing.T) { + testCases := []struct { + name string + yaml string + inputEvent string + expectedOutput *FilterPatterns + }{ + { + name: "on.push.branches", + yaml: ` +name: local-action-docker-url +on: + push: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Branches: []string{"master"}}, + }, + { + name: "on.push.branches - alternate syntax", + yaml: ` +name: local-action-docker-url +on: + push: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Branches: []string{"master"}}, + }, + { + name: "on.push.branches-ignore", + yaml: ` +name: local-action-docker-url +on: + push: + branches-ignore: + - "**test" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{BranchesIgnore: []string{"**test"}}, + }, + { + name: "on.push.tags", + yaml: ` +name: local-action-docker-url +on: + push: + tags: + - "*-release" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Tags: []string{"*-release"}}, + }, + { + name: "on.push.tags - alternate syntax", + yaml: ` +name: local-action-docker-url +on: + push: + tags: ["*-release"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Tags: []string{"*-release"}}, + }, + { + name: "on.push.tags-ignore", + yaml: ` +name: local-action-docker-url +on: + push: + tags-ignore: + - "*-alpha" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{TagsIgnore: []string{"*-alpha"}}, + }, + { + name: "on.push.paths", + yaml: ` +name: local-action-docker-url +on: + push: + paths: + - "**.go" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Paths: []string{"**.go"}}, + }, + { + name: "on.push.paths - alternate syntax", + yaml: ` +name: local-action-docker-url +on: + push: + paths: ["**.go"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Paths: []string{"**.go"}}, + }, + { + name: "on.push.paths-ignore", + yaml: ` +name: local-action-docker-url +on: + push: + paths-ignore: + - "**.md" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{PathsIgnore: []string{"**.md"}}, + }, + { + name: "on.pull_request.branches", + yaml: ` +name: local-action-docker-url +on: + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Branches: []string{"master"}}, + }, + { + name: "on.pull_request.branches - alternate syntax", + yaml: ` +name: local-action-docker-url +on: + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Branches: []string{"master"}}, + }, + { + name: "on.pull_request.paths", + yaml: ` +name: local-action-docker-url +on: + pull_request: + paths: + - "**.go" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Paths: []string{"**.go"}}, + }, + { + name: "on.pull_request.paths", + yaml: ` +name: local-action-docker-url +on: + pull_request: + paths: ["**.go"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Paths: []string{"**.go"}}, + }, + { + name: "on.push.tags AND on.push.paths", + yaml: ` +name: local-action-docker-url +on: + push: + tags: + - "*-release" + paths: + - "**.go" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Paths: []string{"**.go"}, Tags: []string{"*-release"}}, + }, + { + name: "on.push.tags AND on.push.paths - alternate syntax", + yaml: ` +name: local-action-docker-url +on: + push: + tags: ["*-release"] + paths: ["**.go"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: &FilterPatterns{Paths: []string{"**.go"}, Tags: []string{"*-release"}}, + }, + { + name: "on.pull_request.branches AND on.pull_request.paths", + yaml: ` +name: local-action-docker-url +on: + pull_request: + branches: + - master + paths: + - "**.go" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Branches: []string{"master"}, Paths: []string{"**.go"}}, + }, + { + name: "on.pull_request.branches AND on.pull_request.paths - alternate syntax", + yaml: ` +name: local-action-docker-url +on: + pull_request: + branches: [master] + paths: ["**.go"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Branches: []string{"master"}, Paths: []string{"**.go"}}, + }, + { + name: "on.pull_request.branches AND on.push.tags", + yaml: ` +name: local-action-docker-url +on: + pull_request: + branches: + - "master" + - "rc" + push: + tags: + - "*-release" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Branches: []string{"master", "rc"}}, + }, + { + name: "on.pull_request.branches AND on.push.tags - alternate syntax", + yaml: ` +name: local-action-docker-url +on: + pull_request: + branches: ["master", "rc"] + push: + tags: ["*-release"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "pull_request", + expectedOutput: &FilterPatterns{Branches: []string{"master", "rc"}}, + }, + { + name: "on.push - no filters supplied", + yaml: ` +name: local-action-docker-url +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "push", + expectedOutput: nil, + }, + { + name: "on.schedule - filters not supported", + yaml: ` +name: local-action-docker-url +on: + schedule: + - cron: $cron-weekly + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: ./actions/docker-url +`, + inputEvent: "schedule", + expectedOutput: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + workflow, err := ReadWorkflow(strings.NewReader(tc.yaml)) + assert.NoError(t, err, "read workflow should succeed") + + assert.Equal(t, tc.expectedOutput, workflow.FindFilterPatterns(tc.inputEvent)) + }) + } +} + func TestReadWorkflow_StringContainer(t *testing.T) { yaml := ` name: local-action-docker-url diff --git a/pkg/workflowpattern/workflow_pattern.go b/pkg/workflowpattern/workflow_pattern.go index cc03e405626..5184ea64b6b 100644 --- a/pkg/workflowpattern/workflow_pattern.go +++ b/pkg/workflowpattern/workflow_pattern.go @@ -147,6 +147,9 @@ func CompilePatterns(patterns ...string) ([]*WorkflowPattern, error) { return ret, nil } +//FilterInputsFunc defines the signature that both Skip() and Filter() implement +type FilterInputsFunc func(sequence []*WorkflowPattern, input []string, traceWriter TraceWriter) bool + // returns true if the workflow should be skipped paths/branches func Skip(sequence []*WorkflowPattern, input []string, traceWriter TraceWriter) bool { if len(sequence) == 0 {