diff --git a/go.mod b/go.mod index dc80d2ca2bb3a..57c9a7fd62253 100644 --- a/go.mod +++ b/go.mod @@ -102,6 +102,7 @@ require ( github.com/prometheus/client_golang v1.20.5 github.com/quasoft/websspi v1.1.2 github.com/redis/go-redis/v9 v9.7.0 + github.com/rhysd/actionlint v1.7.3 github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sassoftware/go-rpmutils v0.4.0 @@ -271,7 +272,6 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.60.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/rhysd/actionlint v1.7.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rs/xid v1.6.0 // indirect diff --git a/models/actions/run.go b/models/actions/run.go index a224a910ab59a..e8a6ad482a2ce 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" @@ -20,7 +21,6 @@ import ( "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" - "github.com/nektos/act/pkg/jobparser" "xorm.io/builder" ) diff --git a/models/actions/task.go b/models/actions/task.go index 9f13ff94c9e4a..5bdcf36ba2c93 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -12,6 +12,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -20,7 +21,6 @@ import ( runnerv1 "code.gitea.io/actions-proto-go/runner/v1" lru "github.com/hashicorp/golang-lru/v2" - "github.com/nektos/act/pkg/jobparser" "google.golang.org/protobuf/types/known/timestamppb" "xorm.io/builder" ) diff --git a/modules/actions/github.go b/modules/actions/github.go index 68116ec83a539..e08aeca65de82 100644 --- a/modules/actions/github.go +++ b/modules/actions/github.go @@ -26,8 +26,8 @@ const ( ) // IsDefaultBranchWorkflow returns true if the event only triggers workflows on the default branch -func IsDefaultBranchWorkflow(triggedEvent webhook_module.HookEventType) bool { - switch triggedEvent { +func IsDefaultBranchWorkflow(triggeredEvent webhook_module.HookEventType) bool { + switch triggeredEvent { case webhook_module.HookEventDelete: // GitHub "delete" event // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#delete diff --git a/modules/actions/jobparser/evaluator.go b/modules/actions/jobparser/evaluator.go new file mode 100644 index 0000000000000..baeeea3e4cb11 --- /dev/null +++ b/modules/actions/jobparser/evaluator.go @@ -0,0 +1,188 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "fmt" + "regexp" + "strings" + + "github.com/nektos/act/pkg/exprparser" + "gopkg.in/yaml.v3" +) + +// ExpressionEvaluator is copied from runner.expressionEvaluator, +// to avoid unnecessary dependencies +type ExpressionEvaluator struct { + interpreter exprparser.Interpreter +} + +func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvaluator { + return &ExpressionEvaluator{interpreter: interpreter} +} + +func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) { + evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck) + + return evaluated, err +} + +func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error { + var in string + if err := node.Decode(&in); err != nil { + return err + } + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return nil + } + expr := rewriteSubExpression(in, false) + res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone) + if err != nil { + return err + } + return node.Encode(res) +} + +func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error { + // GitHub has this undocumented feature to merge maps, called insert directive + insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`) + for i := 0; i < len(node.Content)/2; { + k := node.Content[i*2] + v := node.Content[i*2+1] + if err := ee.EvaluateYamlNode(v); err != nil { + return err + } + var sk string + // Merge the nested map of the insert directive + if k.Decode(&sk) == nil && insertDirective.MatchString(sk) { + node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...) + i += len(v.Content) / 2 + } else { + if err := ee.EvaluateYamlNode(k); err != nil { + return err + } + i++ + } + } + return nil +} + +func (ee ExpressionEvaluator) evaluateSequenceYamlNode(node *yaml.Node) error { + for i := 0; i < len(node.Content); { + v := node.Content[i] + // Preserve nested sequences + wasseq := v.Kind == yaml.SequenceNode + if err := ee.EvaluateYamlNode(v); err != nil { + return err + } + // GitHub has this undocumented feature to merge sequences / arrays + // We have a nested sequence via evaluation, merge the arrays + if v.Kind == yaml.SequenceNode && !wasseq { + node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...) + i += len(v.Content) + } else { + i++ + } + } + return nil +} + +func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + return ee.evaluateScalarYamlNode(node) + case yaml.MappingNode: + return ee.evaluateMappingYamlNode(node) + case yaml.SequenceNode: + return ee.evaluateSequenceYamlNode(node) + default: + return nil + } +} + +func (ee ExpressionEvaluator) Interpolate(in string) string { + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return in + } + + expr := rewriteSubExpression(in, true) + evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone) + if err != nil { + return "" + } + + value, ok := evaluated.(string) + if !ok { + panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr)) + } + + return value +} + +func escapeFormatString(in string) string { + return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}") +} + +func rewriteSubExpression(in string, forceFormat bool) string { + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return in + } + + strPattern := regexp.MustCompile("(?:''|[^'])*'") + pos := 0 + exprStart := -1 + strStart := -1 + var results []string + formatOut := "" + for pos < len(in) { + if strStart > -1 { + matches := strPattern.FindStringIndex(in[pos:]) + if matches == nil { + panic("unclosed string.") + } + + strStart = -1 + pos += matches[1] + } else if exprStart > -1 { + exprEnd := strings.Index(in[pos:], "}}") + strStart = strings.Index(in[pos:], "'") + + if exprEnd > -1 && strStart > -1 { + if exprEnd < strStart { + strStart = -1 + } else { + exprEnd = -1 + } + } + + if exprEnd > -1 { + formatOut += fmt.Sprintf("{%d}", len(results)) + results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd])) + pos += exprEnd + 2 + exprStart = -1 + } else if strStart > -1 { + pos += strStart + 1 + } else { + panic("unclosed expression.") + } + } else { + exprStart = strings.Index(in[pos:], "${{") + if exprStart != -1 { + formatOut += escapeFormatString(in[pos : pos+exprStart]) + exprStart = pos + exprStart + 3 + pos = exprStart + } else { + formatOut += escapeFormatString(in[pos:]) + pos = len(in) + } + } + } + + if len(results) == 1 && formatOut == "{0}" && !forceFormat { + return in + } + + out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", ")) + return out +} diff --git a/modules/actions/jobparser/interpeter.go b/modules/actions/jobparser/interpeter.go new file mode 100644 index 0000000000000..672a998f3b8cd --- /dev/null +++ b/modules/actions/jobparser/interpeter.go @@ -0,0 +1,86 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "github.com/nektos/act/pkg/exprparser" + "github.com/nektos/act/pkg/model" + "gopkg.in/yaml.v3" +) + +// NewInterpreter returns an interpreter used in the server, +// need github, needs, strategy, matrix, inputs context only, +// see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability +func NewInterpreter( + jobID string, + job *model.Job, + matrix map[string]any, + gitCtx *model.GithubContext, + results map[string]*JobResult, + vars map[string]string, +) exprparser.Interpreter { + strategy := make(map[string]any) + if job.Strategy != nil { + strategy["fail-fast"] = job.Strategy.FailFast + strategy["max-parallel"] = job.Strategy.MaxParallel + } + + run := &model.Run{ + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{}, + }, + JobID: jobID, + } + for id, result := range results { + need := yaml.Node{} + _ = need.Encode(result.Needs) + run.Workflow.Jobs[id] = &model.Job{ + RawNeeds: need, + Result: result.Result, + Outputs: result.Outputs, + } + } + + jobs := run.Workflow.Jobs + jobNeeds := run.Job().Needs() + + using := map[string]exprparser.Needs{} + for _, need := range jobNeeds { + if v, ok := jobs[need]; ok { + using[need] = exprparser.Needs{ + Outputs: v.Outputs, + Result: v.Result, + } + } + } + + ee := &exprparser.EvaluationEnvironment{ + Github: gitCtx, + Env: nil, // no need + Job: nil, // no need + Steps: nil, // no need + Runner: nil, // no need + Secrets: nil, // no need + Strategy: strategy, + Matrix: matrix, + Needs: using, + Inputs: nil, // not supported yet + Vars: vars, + } + + config := exprparser.Config{ + Run: run, + WorkingDir: "", // WorkingDir is used for the function hashFiles, but it's not needed in the server + Context: "job", + } + + return exprparser.NewInterpeter(ee, config) +} + +// JobResult is the minimum requirement of job results for Interpreter +type JobResult struct { + Needs []string + Result string + Outputs map[string]string +} diff --git a/modules/actions/jobparser/jobparser.go b/modules/actions/jobparser/jobparser.go new file mode 100644 index 0000000000000..1f9500ffbae7e --- /dev/null +++ b/modules/actions/jobparser/jobparser.go @@ -0,0 +1,163 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/nektos/act/pkg/model" + "gopkg.in/yaml.v3" +) + +func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) { + origin, err := model.ReadWorkflow(bytes.NewReader(content)) + if err != nil { + return nil, fmt.Errorf("model.ReadWorkflow: %w", err) + } + + workflow := &SingleWorkflow{} + if err := yaml.Unmarshal(content, workflow); err != nil { + return nil, fmt.Errorf("yaml.Unmarshal: %w", err) + } + + pc := &parseContext{} + for _, o := range options { + o(pc) + } + results := map[string]*JobResult{} + for id, job := range origin.Jobs { + results[id] = &JobResult{ + Needs: job.Needs(), + Result: pc.jobResults[id], + Outputs: nil, // not supported yet + } + } + + var ret []*SingleWorkflow + ids, jobs, err := workflow.jobs() + if err != nil { + return nil, fmt.Errorf("invalid jobs: %w", err) + } + for i, id := range ids { + job := jobs[i] + matricxes, err := getMatrixes(origin.GetJob(id)) + if err != nil { + return nil, fmt.Errorf("getMatrixes: %w", err) + } + for _, matrix := range matricxes { + job := job.Clone() + if job.Name == "" { + job.Name = id + } + job.Strategy.RawMatrix = encodeMatrix(matrix) + evaluator := NewExpressionEvaluator(NewInterpreter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars)) + job.Name = nameWithMatrix(job.Name, matrix, evaluator) + runsOn := origin.GetJob(id).RunsOn() + for i, v := range runsOn { + runsOn[i] = evaluator.Interpolate(v) + } + job.RawRunsOn = encodeRunsOn(runsOn) + swf := &SingleWorkflow{ + Name: workflow.Name, + RawOn: workflow.RawOn, + Env: workflow.Env, + Defaults: workflow.Defaults, + } + if err := swf.SetJob(id, job); err != nil { + return nil, fmt.Errorf("SetJob: %w", err) + } + ret = append(ret, swf) + } + } + return ret, nil +} + +func WithJobResults(results map[string]string) ParseOption { + return func(c *parseContext) { + c.jobResults = results + } +} + +func WithGitContext(context *model.GithubContext) ParseOption { + return func(c *parseContext) { + c.gitContext = context + } +} + +func WithVars(vars map[string]string) ParseOption { + return func(c *parseContext) { + c.vars = vars + } +} + +type parseContext struct { + jobResults map[string]string + gitContext *model.GithubContext + vars map[string]string +} + +type ParseOption func(c *parseContext) + +func getMatrixes(job *model.Job) ([]map[string]any, error) { + ret, err := job.GetMatrixes() + if err != nil { + return nil, fmt.Errorf("GetMatrixes: %w", err) + } + sort.Slice(ret, func(i, j int) bool { + return matrixName(ret[i]) < matrixName(ret[j]) + }) + return ret, nil +} + +func encodeMatrix(matrix map[string]any) yaml.Node { + if len(matrix) == 0 { + return yaml.Node{} + } + value := map[string][]any{} + for k, v := range matrix { + value[k] = []any{v} + } + node := yaml.Node{} + _ = node.Encode(value) + return node +} + +func encodeRunsOn(runsOn []string) yaml.Node { + node := yaml.Node{} + if len(runsOn) == 1 { + _ = node.Encode(runsOn[0]) + } else { + _ = node.Encode(runsOn) + } + return node +} + +func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string { + if len(m) == 0 { + return name + } + + if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") { + return name + " " + matrixName(m) + } + + return evaluator.Interpolate(name) +} + +func matrixName(m map[string]any) string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + vs := make([]string, 0, len(m)) + for _, v := range ks { + vs = append(vs, fmt.Sprint(m[v])) + } + + return fmt.Sprintf("(%s)", strings.Join(vs, ", ")) +} diff --git a/modules/actions/jobparser/jobparser_test.go b/modules/actions/jobparser/jobparser_test.go new file mode 100644 index 0000000000000..084eed009c6a8 --- /dev/null +++ b/modules/actions/jobparser/jobparser_test.go @@ -0,0 +1,82 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + options []ParseOption + wantErr bool + }{ + { + name: "multiple_jobs", + options: nil, + wantErr: false, + }, + { + name: "multiple_matrix", + options: nil, + wantErr: false, + }, + { + name: "has_needs", + options: nil, + wantErr: false, + }, + { + name: "has_with", + options: nil, + wantErr: false, + }, + { + name: "has_secrets", + options: nil, + wantErr: false, + }, + { + name: "empty_step", + options: nil, + wantErr: false, + }, + { + name: "job_name_with_matrix", + options: nil, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := ReadTestdata(t, tt.name+".in.yaml") + want := ReadTestdata(t, tt.name+".out.yaml") + got, err := Parse(content, tt.options...) + if tt.wantErr { + require.Error(t, err) + } + require.NoError(t, err) + + builder := &strings.Builder{} + for _, v := range got { + if builder.Len() > 0 { + builder.WriteString("---\n") + } + encoder := yaml.NewEncoder(builder) + encoder.SetIndent(2) + require.NoError(t, encoder.Encode(v)) + id, job := v.Job() + assert.NotEmpty(t, id) + assert.NotNil(t, job) + } + assert.Equal(t, string(want), builder.String()) + }) + } +} diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go new file mode 100644 index 0000000000000..4383bba53ec01 --- /dev/null +++ b/modules/actions/jobparser/model.go @@ -0,0 +1,352 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "errors" + "fmt" + + "github.com/nektos/act/pkg/model" + "github.com/rhysd/actionlint" + "gopkg.in/yaml.v3" +) + +// SingleWorkflow is a workflow with single job and single matrix +type SingleWorkflow struct { + Name string `yaml:"name,omitempty"` + RawOn yaml.Node `yaml:"on,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + RawJobs yaml.Node `yaml:"jobs,omitempty"` + Defaults Defaults `yaml:"defaults,omitempty"` +} + +func (w *SingleWorkflow) Job() (string, *Job) { + ids, jobs, _ := w.jobs() + if len(ids) >= 1 { + return ids[0], jobs[0] + } + return "", nil +} + +func (w *SingleWorkflow) jobs() ([]string, []*Job, error) { + ids, jobs, err := parseMappingNode[*Job](&w.RawJobs) + if err != nil { + return nil, nil, err + } + + for _, job := range jobs { + steps := make([]*Step, 0, len(job.Steps)) + for _, s := range job.Steps { + if s != nil { + steps = append(steps, s) + } + } + job.Steps = steps + } + + return ids, jobs, nil +} + +func (w *SingleWorkflow) SetJob(id string, job *Job) error { + m := map[string]*Job{ + id: job, + } + out, err := yaml.Marshal(m) + if err != nil { + return err + } + node := yaml.Node{} + if err := yaml.Unmarshal(out, &node); err != nil { + return err + } + if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("can not set job: %q", out) + } + w.RawJobs = *node.Content[0] + return nil +} + +func (w *SingleWorkflow) Marshal() ([]byte, error) { + return yaml.Marshal(w) +} + +type Job struct { + Name string `yaml:"name,omitempty"` + RawNeeds yaml.Node `yaml:"needs,omitempty"` + RawRunsOn yaml.Node `yaml:"runs-on,omitempty"` + Env yaml.Node `yaml:"env,omitempty"` + If yaml.Node `yaml:"if,omitempty"` + Steps []*Step `yaml:"steps,omitempty"` + TimeoutMinutes string `yaml:"timeout-minutes,omitempty"` + Services map[string]*ContainerSpec `yaml:"services,omitempty"` + Strategy Strategy `yaml:"strategy,omitempty"` + RawContainer yaml.Node `yaml:"container,omitempty"` + Defaults Defaults `yaml:"defaults,omitempty"` + Outputs map[string]string `yaml:"outputs,omitempty"` + Uses string `yaml:"uses,omitempty"` + With map[string]any `yaml:"with,omitempty"` + RawSecrets yaml.Node `yaml:"secrets,omitempty"` +} + +func (j *Job) Clone() *Job { + if j == nil { + return nil + } + return &Job{ + Name: j.Name, + RawNeeds: j.RawNeeds, + RawRunsOn: j.RawRunsOn, + Env: j.Env, + If: j.If, + Steps: j.Steps, + TimeoutMinutes: j.TimeoutMinutes, + Services: j.Services, + Strategy: j.Strategy, + RawContainer: j.RawContainer, + Defaults: j.Defaults, + Outputs: j.Outputs, + Uses: j.Uses, + With: j.With, + RawSecrets: j.RawSecrets, + } +} + +func (j *Job) Needs() []string { + return (&model.Job{RawNeeds: j.RawNeeds}).Needs() +} + +func (j *Job) EraseNeeds() *Job { + j.RawNeeds = yaml.Node{} + return j +} + +func (j *Job) RunsOn() []string { + return (&model.Job{RawRunsOn: j.RawRunsOn}).RunsOn() +} + +type Step struct { + ID string `yaml:"id,omitempty"` + If yaml.Node `yaml:"if,omitempty"` + Name string `yaml:"name,omitempty"` + Uses string `yaml:"uses,omitempty"` + Run string `yaml:"run,omitempty"` + WorkingDirectory string `yaml:"working-directory,omitempty"` + Shell string `yaml:"shell,omitempty"` + Env yaml.Node `yaml:"env,omitempty"` + With map[string]string `yaml:"with,omitempty"` + ContinueOnError bool `yaml:"continue-on-error,omitempty"` + TimeoutMinutes string `yaml:"timeout-minutes,omitempty"` +} + +// String gets the name of step +func (s *Step) String() string { + if s == nil { + return "" + } + return (&model.Step{ + ID: s.ID, + Name: s.Name, + Uses: s.Uses, + Run: s.Run, + }).String() +} + +type ContainerSpec struct { + Image string `yaml:"image,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + Ports []string `yaml:"ports,omitempty"` + Volumes []string `yaml:"volumes,omitempty"` + Options string `yaml:"options,omitempty"` + Credentials map[string]string `yaml:"credentials,omitempty"` + Cmd []string `yaml:"cmd,omitempty"` +} + +type Strategy struct { + FailFastString string `yaml:"fail-fast,omitempty"` + MaxParallelString string `yaml:"max-parallel,omitempty"` + RawMatrix yaml.Node `yaml:"matrix,omitempty"` +} + +type Defaults struct { + Run RunDefaults `yaml:"run,omitempty"` +} + +type RunDefaults struct { + Shell string `yaml:"shell,omitempty"` + WorkingDirectory string `yaml:"working-directory,omitempty"` +} + +type WorkflowDispatchInput struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + Default string `yaml:"default"` + Type string `yaml:"type"` + Options []string `yaml:"options"` +} + +type Event struct { + Name string + acts map[string][]string + schedules []map[string]string + inputs []WorkflowDispatchInput +} + +func (evt *Event) IsSchedule() bool { + return evt.schedules != nil +} + +func (evt *Event) Acts() map[string][]string { + return evt.acts +} + +// Helper to convert actionlint errors +func acErrToError(acErrs []*actionlint.Error) []error { + errs := make([]error, len(acErrs)) + for i, err := range acErrs { + errs[i] = err + } + return errs +} + +func acStringToString(strs []*actionlint.String) []string { + if len(strs) == 0 { + return nil + } + strings := make([]string, len(strs)) + for i, v := range strs { + strings[i] = v.Value + } + return strings +} + +func typeToString(typ actionlint.WorkflowDispatchEventInputType) string { + switch typ { + case actionlint.WorkflowDispatchEventInputTypeString: + return "string" + case actionlint.WorkflowDispatchEventInputTypeBoolean: + return "boolean" + case actionlint.WorkflowDispatchEventInputTypeChoice: + return "choice" + case actionlint.WorkflowDispatchEventInputTypeEnvironment: + return "environment" + case actionlint.WorkflowDispatchEventInputTypeNumber: + return "number" + default: + return "" + } +} + +func GetEventsFromContent(content []byte) ([]*Event, error) { + wf, errs := actionlint.Parse(content) + + if len(errs) != 0 { + return nil, errors.Join(acErrToError(errs)...) + } + + events := make([]*Event, 0, len(wf.On)) + for _, acEvent := range wf.On { + event := &Event{ + Name: acEvent.EventName(), + } + switch e := acEvent.(type) { + case *actionlint.ScheduledEvent: + schedules := make([]map[string]string, len(e.Cron)) + for i, c := range e.Cron { + schedules[i] = map[string]string{"cron": c.Value} + } + event.schedules = schedules + case *actionlint.WorkflowDispatchEvent: + inputs := make([]WorkflowDispatchInput, len(e.Inputs)) + i := 0 + for keyword, v := range e.Inputs { + wdi := WorkflowDispatchInput{ + Name: keyword, + + Options: acStringToString(v.Options), + Type: typeToString(v.Type), + } + if v.Required != nil { + wdi.Required = v.Required.Value + } + if v.Description != nil { + wdi.Description = v.Description.Value + } + if v.Default != nil { + wdi.Default = v.Default.Value + } + inputs[i] = wdi + i++ + } + event.inputs = inputs + case *actionlint.WebhookEvent: + event.acts = map[string][]string{} + if e.Branches != nil { + event.acts[e.Branches.Name.Value] = acStringToString(e.Branches.Values) + } + if e.BranchesIgnore != nil { + event.acts[e.BranchesIgnore.Name.Value] = acStringToString(e.BranchesIgnore.Values) + } + if e.Paths != nil { + event.acts[e.Paths.Name.Value] = acStringToString(e.Paths.Values) + } + if e.PathsIgnore != nil { + event.acts[e.PathsIgnore.Name.Value] = acStringToString(e.PathsIgnore.Values) + } + if e.Tags != nil { + event.acts[e.Tags.Name.Value] = acStringToString(e.Tags.Values) + } + if e.TagsIgnore != nil { + event.acts[e.TagsIgnore.Name.Value] = acStringToString(e.TagsIgnore.Values) + } + if e.Types != nil { + event.acts["types"] = acStringToString(e.Types) + } + } + events = append(events, event) + } + return events, nil +} + +func ValidateWorkflow(content []byte) error { + _, errs := actionlint.Parse(content) + err := make([]error, len(errs)) + for _, e := range errs { + err = append(err, fmt.Errorf("%d:%d: %s", e.Line, e.Column, e.Message)) + } + return errors.Join(err...) +} + +// parseMappingNode parse a mapping node and preserve order. +func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) { + if node.Kind != yaml.MappingNode { + return nil, nil, fmt.Errorf("input node is not a mapping node") + } + + var scalars []string + var datas []T + expectKey := true + for _, item := range node.Content { + if expectKey { + if item.Kind != yaml.ScalarNode { + return nil, nil, fmt.Errorf("not a valid scalar node: %v", item.Value) + } + scalars = append(scalars, item.Value) + expectKey = false + } else { + var val T + if err := item.Decode(&val); err != nil { + return nil, nil, err + } + datas = append(datas, val) + expectKey = true + } + } + + if len(scalars) != len(datas) { + return nil, nil, fmt.Errorf("invalid definition of on: %v", node.Value) + } + + return scalars, datas, nil +} diff --git a/modules/actions/jobparser/model_test.go b/modules/actions/jobparser/model_test.go new file mode 100644 index 0000000000000..c0babd7ec5433 --- /dev/null +++ b/modules/actions/jobparser/model_test.go @@ -0,0 +1,173 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "strings" + "testing" + + "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestGetEvents(t *testing.T) { + content := ` +name: My Workflow + +on: + push: + branches: [main] + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + inputs: + my_variable1: + description: 'first variable' + required: true + type: string + my_variable2: + description: 'second variable' + required: false + type: number + default: 4 + +jobs: + example: + runs-on: ubuntu-latest + steps: + - run: exit 0 +` + expected := make([]*Event, 3) + expected[0] = &Event{acts: map[string][]string{"branches": {"main"}}, Name: "push"} + expected[1] = &Event{Name: "schedule", schedules: []map[string]string{{"cron": "0 0 * * *"}}} + expected[2] = &Event{ + Name: "workflow_dispatch", + inputs: []WorkflowDispatchInput{ + { + Name: "my_variable1", + Description: "first variable", + Required: true, + Type: "string", + }, + { + Name: "my_variable2", + Type: "number", + Description: "second variable", + Default: "4", + }, + }, + } + actual, err := GetEventsFromContent([]byte(content)) + require.NoError(t, err) + assert.Len(t, actual, 3) + assert.Equal(t, expected, actual) + //Toggler +} + +func TestSingleWorkflow_SetJob(t *testing.T) { + t.Run("erase needs", func(t *testing.T) { + content := ReadTestdata(t, "erase_needs.in.yaml") + want := ReadTestdata(t, "erase_needs.out.yaml") + swf, err := Parse(content) + require.NoError(t, err) + builder := &strings.Builder{} + for _, v := range swf { + id, job := v.Job() + require.NoError(t, v.SetJob(id, job.EraseNeeds())) + + if builder.Len() > 0 { + builder.WriteString("---\n") + } + encoder := yaml.NewEncoder(builder) + encoder.SetIndent(2) + require.NoError(t, encoder.Encode(v)) + } + assert.Equal(t, string(want), builder.String()) + }) +} + +func TestParseMappingNode(t *testing.T) { + tests := []struct { + input string + scalars []string + datas []any + }{ + { + input: "on:\n push:\n branches:\n - master", + scalars: []string{"push"}, + datas: []any{ + map[string]any{ + "branches": []any{"master"}, + }, + }, + }, + { + input: "on:\n branch_protection_rule:\n types: [created, deleted]", + scalars: []string{"branch_protection_rule"}, + datas: []any{ + map[string]any{ + "types": []any{"created", "deleted"}, + }, + }, + }, + { + input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]", + scalars: []string{"project", "milestone"}, + datas: []any{ + map[string]any{ + "types": []any{"created", "deleted"}, + }, + map[string]any{ + "types": []any{"opened", "deleted"}, + }, + }, + }, + { + input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'", + scalars: []string{"pull_request"}, + datas: []any{ + map[string]any{ + "types": []any{"opened"}, + "branches": []any{"releases/**"}, + }, + }, + }, + { + input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'", + scalars: []string{"push", "pull_request"}, + datas: []any{ + map[string]any{ + "branches": []any{"main"}, + }, + map[string]any{ + "types": []any{"opened"}, + "branches": []any{"**"}, + }, + }, + }, + { + input: "on:\n schedule:\n - cron: '20 6 * * *'", + scalars: []string{"schedule"}, + datas: []any{ + []any{map[string]any{ + "cron": "20 6 * * *", + }}, + }, + }, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + workflow, err := model.ReadWorkflow(strings.NewReader(test.input)) + assert.NoError(t, err) + + scalars, datas, err := parseMappingNode[any](&workflow.RawOn) + assert.NoError(t, err) + assert.EqualValues(t, test.scalars, scalars, "%#v", scalars) + assert.EqualValues(t, test.datas, datas, "%#v", datas) + }) + } +} diff --git a/modules/actions/jobparser/testdata/empty_step.in.yaml b/modules/actions/jobparser/testdata/empty_step.in.yaml new file mode 100644 index 0000000000000..737ac0b2c607e --- /dev/null +++ b/modules/actions/jobparser/testdata/empty_step.in.yaml @@ -0,0 +1,8 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: echo job-1 + - diff --git a/modules/actions/jobparser/testdata/empty_step.out.yaml b/modules/actions/jobparser/testdata/empty_step.out.yaml new file mode 100644 index 0000000000000..06828e0ed7258 --- /dev/null +++ b/modules/actions/jobparser/testdata/empty_step.out.yaml @@ -0,0 +1,7 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: echo job-1 diff --git a/modules/actions/jobparser/testdata/erase_needs.in.yaml b/modules/actions/jobparser/testdata/erase_needs.in.yaml new file mode 100644 index 0000000000000..a7d1f9b631d2a --- /dev/null +++ b/modules/actions/jobparser/testdata/erase_needs.in.yaml @@ -0,0 +1,16 @@ +name: test +jobs: + job1: + runs-on: linux + steps: + - run: uname -a + job2: + runs-on: linux + steps: + - run: uname -a + needs: job1 + job3: + runs-on: linux + steps: + - run: uname -a + needs: [job1, job2] diff --git a/modules/actions/jobparser/testdata/erase_needs.out.yaml b/modules/actions/jobparser/testdata/erase_needs.out.yaml new file mode 100644 index 0000000000000..959960d62d9c3 --- /dev/null +++ b/modules/actions/jobparser/testdata/erase_needs.out.yaml @@ -0,0 +1,23 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job3: + name: job3 + runs-on: linux + steps: + - run: uname -a diff --git a/modules/actions/jobparser/testdata/has_needs.in.yaml b/modules/actions/jobparser/testdata/has_needs.in.yaml new file mode 100644 index 0000000000000..a7d1f9b631d2a --- /dev/null +++ b/modules/actions/jobparser/testdata/has_needs.in.yaml @@ -0,0 +1,16 @@ +name: test +jobs: + job1: + runs-on: linux + steps: + - run: uname -a + job2: + runs-on: linux + steps: + - run: uname -a + needs: job1 + job3: + runs-on: linux + steps: + - run: uname -a + needs: [job1, job2] diff --git a/modules/actions/jobparser/testdata/has_needs.out.yaml b/modules/actions/jobparser/testdata/has_needs.out.yaml new file mode 100644 index 0000000000000..a544aa2223b97 --- /dev/null +++ b/modules/actions/jobparser/testdata/has_needs.out.yaml @@ -0,0 +1,25 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job2: + name: job2 + needs: job1 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job3: + name: job3 + needs: [job1, job2] + runs-on: linux + steps: + - run: uname -a diff --git a/modules/actions/jobparser/testdata/has_secrets.in.yaml b/modules/actions/jobparser/testdata/has_secrets.in.yaml new file mode 100644 index 0000000000000..64b9f69f08584 --- /dev/null +++ b/modules/actions/jobparser/testdata/has_secrets.in.yaml @@ -0,0 +1,14 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: + secret: hideme + + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: inherit diff --git a/modules/actions/jobparser/testdata/has_secrets.out.yaml b/modules/actions/jobparser/testdata/has_secrets.out.yaml new file mode 100644 index 0000000000000..23dfb80367ba8 --- /dev/null +++ b/modules/actions/jobparser/testdata/has_secrets.out.yaml @@ -0,0 +1,16 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: + secret: hideme +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: inherit diff --git a/modules/actions/jobparser/testdata/has_with.in.yaml b/modules/actions/jobparser/testdata/has_with.in.yaml new file mode 100644 index 0000000000000..4e3dc7451cc4b --- /dev/null +++ b/modules/actions/jobparser/testdata/has_with.in.yaml @@ -0,0 +1,15 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: service + + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: module diff --git a/modules/actions/jobparser/testdata/has_with.out.yaml b/modules/actions/jobparser/testdata/has_with.out.yaml new file mode 100644 index 0000000000000..de79b8031269c --- /dev/null +++ b/modules/actions/jobparser/testdata/has_with.out.yaml @@ -0,0 +1,17 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: service +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: module diff --git a/modules/actions/jobparser/testdata/job_name_with_matrix.in.yaml b/modules/actions/jobparser/testdata/job_name_with_matrix.in.yaml new file mode 100644 index 0000000000000..7b5edf74643cc --- /dev/null +++ b/modules/actions/jobparser/testdata/job_name_with_matrix.in.yaml @@ -0,0 +1,14 @@ +name: test +jobs: + job1: + strategy: + matrix: + os: [ubuntu-22.04, ubuntu-20.04] + version: [1.17, 1.18, 1.19] + runs-on: ${{ matrix.os }} + name: test_version_${{ matrix.version }}_on_${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version \ No newline at end of file diff --git a/modules/actions/jobparser/testdata/job_name_with_matrix.out.yaml b/modules/actions/jobparser/testdata/job_name_with_matrix.out.yaml new file mode 100644 index 0000000000000..081e8d4abd6ee --- /dev/null +++ b/modules/actions/jobparser/testdata/job_name_with_matrix.out.yaml @@ -0,0 +1,101 @@ +name: test +jobs: + job1: + name: test_version_1.17_on_ubuntu-20.04 + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: test_version_1.18_on_ubuntu-20.04 + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: test_version_1.19_on_ubuntu-20.04 + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.19 +--- +name: test +jobs: + job1: + name: test_version_1.17_on_ubuntu-22.04 + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: test_version_1.18_on_ubuntu-22.04 + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: test_version_1.19_on_ubuntu-22.04 + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.19 diff --git a/modules/actions/jobparser/testdata/multiple_jobs.in.yaml b/modules/actions/jobparser/testdata/multiple_jobs.in.yaml new file mode 100644 index 0000000000000..266ede847a2be --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_jobs.in.yaml @@ -0,0 +1,22 @@ +name: test +jobs: + zzz: + runs-on: linux + steps: + - run: echo zzz + job1: + runs-on: linux + steps: + - run: uname -a && go version + job2: + runs-on: linux + steps: + - run: uname -a && go version + job3: + runs-on: linux + steps: + - run: uname -a && go version + aaa: + runs-on: linux + steps: + - run: uname -a && go version diff --git a/modules/actions/jobparser/testdata/multiple_jobs.out.yaml b/modules/actions/jobparser/testdata/multiple_jobs.out.yaml new file mode 100644 index 0000000000000..ea2235001cc96 --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_jobs.out.yaml @@ -0,0 +1,39 @@ +name: test +jobs: + zzz: + name: zzz + runs-on: linux + steps: + - run: echo zzz +--- +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: uname -a && go version +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + steps: + - run: uname -a && go version +--- +name: test +jobs: + job3: + name: job3 + runs-on: linux + steps: + - run: uname -a && go version +--- +name: test +jobs: + aaa: + name: aaa + runs-on: linux + steps: + - run: uname -a && go version diff --git a/modules/actions/jobparser/testdata/multiple_matrix.in.yaml b/modules/actions/jobparser/testdata/multiple_matrix.in.yaml new file mode 100644 index 0000000000000..99985f3c428fa --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_matrix.in.yaml @@ -0,0 +1,13 @@ +name: test +jobs: + job1: + strategy: + matrix: + os: [ubuntu-22.04, ubuntu-20.04] + version: [1.17, 1.18, 1.19] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version \ No newline at end of file diff --git a/modules/actions/jobparser/testdata/multiple_matrix.out.yaml b/modules/actions/jobparser/testdata/multiple_matrix.out.yaml new file mode 100644 index 0000000000000..e277cdd0eeb64 --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_matrix.out.yaml @@ -0,0 +1,101 @@ +name: test +jobs: + job1: + name: job1 (ubuntu-20.04, 1.17) + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-20.04, 1.18) + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-20.04, 1.19) + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.19 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-22.04, 1.17) + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-22.04, 1.18) + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-22.04, 1.19) + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.19 diff --git a/modules/actions/jobparser/testdata_test.go b/modules/actions/jobparser/testdata_test.go new file mode 100644 index 0000000000000..f08c9c64738ae --- /dev/null +++ b/modules/actions/jobparser/testdata_test.go @@ -0,0 +1,21 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "embed" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +//go:embed testdata +var testdata embed.FS + +func ReadTestdata(t *testing.T, name string) []byte { + content, err := testdata.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + return content +} diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 0d2b0dd9194d9..c4bc730a0b020 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -4,17 +4,16 @@ package actions import ( - "bytes" "io" "strings" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/gobwas/glob" - "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/workflowpattern" "gopkg.in/yaml.v3" @@ -82,23 +81,10 @@ func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) { return content, nil } -func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) { - workflow, err := model.ReadWorkflow(bytes.NewReader(content)) - if err != nil { - return nil, err - } - events, err := jobparser.ParseRawOn(&workflow.RawOn) - if err != nil { - return nil, err - } - - return events, nil -} - func DetectWorkflows( gitRepo *git.Repository, commit *git.Commit, - triggedEvent webhook_module.HookEventType, + triggeredEvent webhook_module.HookEventType, payload api.Payloader, detectSchedule bool, ) ([]*DetectedWorkflow, []*DetectedWorkflow, error) { @@ -116,13 +102,13 @@ func DetectWorkflows( } // one workflow may have multiple events - events, err := GetEventsFromContent(content) + events, err := jobparser.GetEventsFromContent(content) if err != nil { log.Warn("ignore invalid workflow %q: %v", entry.Name(), err) continue } for _, evt := range events { - log.Trace("detect workflow %q for event %#v matching %q", entry.Name(), evt, triggedEvent) + log.Trace("detect workflow %q for event %#v matching %q", entry.Name(), evt, triggeredEvent) if evt.IsSchedule() { if detectSchedule { dwf := &DetectedWorkflow{ @@ -132,7 +118,7 @@ func DetectWorkflows( } schedules = append(schedules, dwf) } - } else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) { + } else if detectMatched(gitRepo, commit, triggeredEvent, payload, evt) { dwf := &DetectedWorkflow{ EntryName: entry.Name(), TriggerEvent: evt, @@ -160,7 +146,7 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D } // one workflow may have multiple events - events, err := GetEventsFromContent(content) + events, err := jobparser.GetEventsFromContent(content) if err != nil { log.Warn("ignore invalid workflow %q: %v", entry.Name(), err) continue diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index c8e1e553fe94b..303461c49bb01 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -6,6 +6,7 @@ package actions import ( "testing" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -15,58 +16,58 @@ import ( func TestDetectMatched(t *testing.T) { testCases := []struct { - desc string - commit *git.Commit - triggedEvent webhook_module.HookEventType - payload api.Payloader - yamlOn string - expected bool + desc string + commit *git.Commit + triggeredEvent webhook_module.HookEventType + payload api.Payloader + yamlOn string + expected bool }{ { - desc: "HookEventCreate(create) matches GithubEventCreate(create)", - triggedEvent: webhook_module.HookEventCreate, - payload: nil, - yamlOn: "on: create", - expected: true, + desc: "HookEventCreate(create) matches GithubEventCreate(create)", + triggeredEvent: webhook_module.HookEventCreate, + payload: nil, + yamlOn: "on: create", + expected: true, }, { - desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)", - triggedEvent: webhook_module.HookEventIssues, - payload: &api.IssuePayload{Action: api.HookIssueOpened}, - yamlOn: "on: issues", - expected: true, + desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)", + triggeredEvent: webhook_module.HookEventIssues, + payload: &api.IssuePayload{Action: api.HookIssueOpened}, + yamlOn: "on: issues", + expected: true, }, { - desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)", - triggedEvent: webhook_module.HookEventIssues, - payload: &api.IssuePayload{Action: api.HookIssueMilestoned}, - yamlOn: "on: issues", - expected: true, + desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)", + triggeredEvent: webhook_module.HookEventIssues, + payload: &api.IssuePayload{Action: api.HookIssueMilestoned}, + yamlOn: "on: issues", + expected: true, }, { - desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)", - triggedEvent: webhook_module.HookEventPullRequestSync, - payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized}, - yamlOn: "on: pull_request", - expected: true, + desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)", + triggeredEvent: webhook_module.HookEventPullRequestSync, + payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized}, + yamlOn: "on: pull_request", + expected: true, }, { - desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type", - triggedEvent: webhook_module.HookEventPullRequest, - payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated}, - yamlOn: "on: pull_request", - expected: false, + desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type", + triggeredEvent: webhook_module.HookEventPullRequest, + payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated}, + yamlOn: "on: pull_request", + expected: false, }, { - desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type", - triggedEvent: webhook_module.HookEventPullRequest, - payload: &api.PullRequestPayload{Action: api.HookIssueClosed}, - yamlOn: "on: pull_request", - expected: false, + desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type", + triggeredEvent: webhook_module.HookEventPullRequest, + payload: &api.PullRequestPayload{Action: api.HookIssueClosed}, + yamlOn: "on: pull_request", + expected: false, }, { - desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches", - triggedEvent: webhook_module.HookEventPullRequest, + desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches", + triggeredEvent: webhook_module.HookEventPullRequest, payload: &api.PullRequestPayload{ Action: api.HookIssueClosed, PullRequest: &api.PullRequest{ @@ -77,62 +78,97 @@ func TestDetectMatched(t *testing.T) { expected: false, }, { - desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type", - triggedEvent: webhook_module.HookEventPullRequest, - payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated}, - yamlOn: "on:\n pull_request:\n types: [labeled]", - expected: true, + desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type", + triggeredEvent: webhook_module.HookEventPullRequest, + payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated}, + yamlOn: "on:\n pull_request:\n types: [labeled]", + expected: true, }, { - desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)", - triggedEvent: webhook_module.HookEventPullRequestReviewComment, - payload: &api.PullRequestPayload{Action: api.HookIssueReviewed}, - yamlOn: "on:\n pull_request_review_comment:\n types: [created]", - expected: true, + desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment) with `created` activity type", + triggeredEvent: webhook_module.HookEventPullRequestReviewComment, + payload: &api.PullRequestPayload{Action: api.HookIssueReviewed}, + yamlOn: "on:\n pull_request_review_comment:\n types: [created]", + expected: true, }, { - desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)", - triggedEvent: webhook_module.HookEventPullRequestReviewRejected, - payload: &api.PullRequestPayload{Action: api.HookIssueReviewed}, - yamlOn: "on:\n pull_request_review:\n types: [dismissed]", - expected: false, + desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)", + triggeredEvent: webhook_module.HookEventPullRequestReviewRejected, + payload: &api.PullRequestPayload{Action: api.HookIssueReviewed}, + yamlOn: "on:\n pull_request_review:\n types: [dismissed]", + expected: false, }, { - desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type", - triggedEvent: webhook_module.HookEventRelease, - payload: &api.ReleasePayload{Action: api.HookReleasePublished}, - yamlOn: "on:\n release:\n types: [published]", - expected: true, + desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)", + triggeredEvent: webhook_module.HookEventPullRequestReviewComment, + payload: &api.PullRequestPayload{Action: api.HookIssueReviewed}, + yamlOn: "on:\n pull_request_review_comment:", + expected: true, }, { - desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type", - triggedEvent: webhook_module.HookEventPackage, - payload: &api.PackagePayload{Action: api.HookPackageCreated}, - yamlOn: "on:\n registry_package:\n types: [updated]", - expected: false, + desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type", + triggeredEvent: webhook_module.HookEventRelease, + payload: &api.ReleasePayload{Action: api.HookReleasePublished}, + yamlOn: "on:\n release:\n types: [published]", + expected: true, }, { - desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)", - triggedEvent: webhook_module.HookEventWiki, - payload: nil, - yamlOn: "on: gollum", - expected: true, + desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type", + triggeredEvent: webhook_module.HookEventPackage, + payload: &api.PackagePayload{Action: api.HookPackageCreated}, + yamlOn: "on:\n registry_package:\n types: [updated]", + expected: false, }, { - desc: "HookEventSchedue(schedule) matches GithubEventSchedule(schedule)", - triggedEvent: webhook_module.HookEventSchedule, - payload: nil, - yamlOn: "on: schedule", - expected: true, + desc: "HookEventRelease(release) `updated` action matches GithubEventRelease(release) with `edited` activity type", + triggeredEvent: webhook_module.HookEventRelease, + payload: &api.ReleasePayload{Action: api.HookReleaseUpdated}, + yamlOn: "on:\n release:\n types: [edited]", + expected: true, + }, + { + desc: "HookEventRelease(release) `updated` action matches GithubEventRelease(release)", + triggeredEvent: webhook_module.HookEventRelease, + payload: &api.ReleasePayload{Action: api.HookReleaseUpdated}, + yamlOn: "on:\n release:", + expected: true, + }, + { + desc: "HookEventPackage(package) `created` action matches GithubEventRegistryPackage(registry_package)", + triggeredEvent: webhook_module.HookEventPackage, + payload: &api.PackagePayload{Action: api.HookPackageCreated}, + yamlOn: "on:\n registry_package:", + expected: true, + }, + { + desc: "HookEventPackage(package) `created` action matches GithubEventRegistryPackage(registry_package)", + triggeredEvent: webhook_module.HookEventPackage, + payload: &api.PackagePayload{Action: api.HookPackageCreated}, + yamlOn: "on:\n registry_package:\n types: [published]", + expected: true, + }, + { + desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)", + triggeredEvent: webhook_module.HookEventWiki, + payload: nil, + yamlOn: "on: gollum", + expected: true, + }, + { + desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)", + triggeredEvent: webhook_module.HookEventSchedule, + payload: nil, + yamlOn: "on:\n schedule:\n - cron: '*/30 * * * *'", + expected: true, }, } - + minJob := "\njobs:\n test:\n runs-on: test\n steps:\n - run: echo 'sleep'" for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - evts, err := GetEventsFromContent([]byte(tc.yamlOn)) + evts, err := jobparser.GetEventsFromContent([]byte(tc.yamlOn + minJob)) assert.NoError(t, err) assert.Len(t, evts, 1) - assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0])) + assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggeredEvent, tc.payload, evts[0])) }) } } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index ba17fa427d1a8..608efef3e5f69 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -25,6 +25,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" @@ -39,7 +40,6 @@ import ( context_module "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" - "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" "xorm.io/builder" ) diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 17c2821824339..5fbb36b26e7ea 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/models/renderhelper" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/highlight" @@ -27,8 +28,6 @@ import ( "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" files_service "code.gitea.io/gitea/services/repository/files" - - "github.com/nektos/act/pkg/model" ) func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { @@ -75,7 +74,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { if err != nil { log.Error("actions.GetContentFromEntry: %v", err) } - _, workFlowErr := model.ReadWorkflow(bytes.NewReader(content)) + workFlowErr := jobparser.ValidateWorkflow(content) if workFlowErr != nil { ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) } diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 7f52c9d31b590..e9404ce78b7a7 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -13,13 +13,12 @@ import ( git_model "code.gitea.io/gitea/models/git" user_model "code.gitea.io/gitea/models/user" actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/actions/jobparser" git "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" - - "github.com/nektos/act/pkg/jobparser" ) // CreateCommitStatus creates a commit status for the given job. diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 1f859fcf70506..d2dc103922dd1 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -10,10 +10,10 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/queue" - "github.com/nektos/act/pkg/jobparser" "xorm.io/builder" ) diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index 323c6a76e422c..7a3911c27d321 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -19,6 +19,7 @@ import ( unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/json" @@ -28,7 +29,6 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" - "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" ) diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index 18f3324fd2c26..4bae977ac0ea7 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -12,11 +12,10 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" webhook_module "code.gitea.io/gitea/modules/webhook" - - "github.com/nektos/act/pkg/jobparser" ) // StartScheduleTasks start the task