diff --git a/config.go b/config.go index 74913f6d7..f3232a68e 100644 --- a/config.go +++ b/config.go @@ -67,6 +67,11 @@ type Config struct { // Paths is a "paths" mapping in the configuration file. The keys are glob patterns to match file paths. // And the values are corresponding configurations applied to the file paths. Paths map[string]PathConfig `yaml:"paths"` + + // RequiredActions specifies which actions must be present in workflows + RequiredActions []RequiredActionRule `yaml:"required-actions"` + + } // PathConfigs returns a list of all PathConfig values matching to the given file path. The path must diff --git a/linter.go b/linter.go index b32687e72..16c363c7b 100644 --- a/linter.go +++ b/linter.go @@ -570,6 +570,12 @@ func (l *Linter) check( NewRuleDeprecatedCommands(), NewRuleIfCond(), } + + // Only add required actions rule if config exists and has required actions + if cfg != nil && len(cfg.RequiredActions) > 0 { + rules = append(rules, NewRuleRequiredActions(cfg.RequiredActions)) + } + if l.shellcheck != "" { r, err := NewRuleShellcheck(l.shellcheck, proc) if err == nil { diff --git a/rule_required_actions.go b/rule_required_actions.go new file mode 100644 index 000000000..51d8cf735 --- /dev/null +++ b/rule_required_actions.go @@ -0,0 +1,104 @@ +// Package actionlint provides linting functionality for GitHub Actions workflows. +package actionlint + +import ( + "fmt" + "strings" +) + +// RequiredActionRule represents a rule that enforces the usage of a specific GitHub Action +// with an optional version constraint in workflows. +type RequiredActionRule struct { + Action string `yaml:"Action"` // Name of the required GitHub Action (e.g., "actions/checkout") + Version string `yaml:"Version"` // Optional version constraint (e.g., "v3") +} + +// RuleRequiredActions implements a linting rule that checks for the presence and version +// of required GitHub Actions within workflows. +type RuleRequiredActions struct { + RuleBase + required []RequiredActionRule +} + +// NewRuleRequiredActions creates a new instance of RuleRequiredActions with the specified +// required actions. Returns nil if no required actions are provided. +func NewRuleRequiredActions(required []RequiredActionRule) *RuleRequiredActions { + if len(required) == 0 { + return nil + } + return &RuleRequiredActions{ + RuleBase: RuleBase{ + name: "required-actions", + desc: "Checks that required GitHub Actions are used in workflows", + }, + required: required, + } +} + +// VisitWorkflowPre analyzes the workflow to ensure all required actions are present +// with correct versions. It reports errors for missing or mismatched versions. +func (rule *RuleRequiredActions) VisitWorkflowPre(workflow *Workflow) error { + if workflow == nil { + return nil + } + + pos := &Pos{Line: 1, Col: 1} + foundActions := make(map[string]string) + + if workflow != nil && len(workflow.Jobs) > 0 { + // Get first job's position + for _, job := range workflow.Jobs { + if job != nil && job.Pos != nil { + pos = job.Pos + break + } + } + + // Check steps in all jobs + for _, job := range workflow.Jobs { + if job == nil || len(job.Steps) == 0 { + continue + } + for _, step := range job.Steps { + if step != nil && step.Exec != nil { + if exec, ok := step.Exec.(*ExecAction); ok && exec.Uses != nil { + name, ver := parseActionRef(exec.Uses.Value) + if name != "" { + foundActions[name] = ver + } + } + } + } + } + } + + // Check required actions + for _, req := range rule.required { + ver, found := foundActions[req.Action] + if !found { + rule.Error(pos, fmt.Sprintf("required action %q (version %q) is not used in this workflow", + req.Action, req.Version)) + continue + } + if req.Version != "" && ver != req.Version { + rule.Error(pos, fmt.Sprintf("action %q must use version %q but found version %q", + req.Action, req.Version, ver)) + } + } + + return nil +} + +// parseActionRef extracts the action name and version from a GitHub Action reference. +// Returns empty strings for invalid references like Docker images or malformed strings. +// Example: "actions/checkout@v3" returns ("actions/checkout", "v3") +func parseActionRef(uses string) (name string, version string) { + if uses == "" || !strings.Contains(uses, "/") || strings.HasPrefix(uses, "docker://") { + return "", "" + } + parts := strings.SplitN(uses, "@", 2) + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} diff --git a/rule_required_actions_test.go b/rule_required_actions_test.go new file mode 100644 index 000000000..d9a0a5278 --- /dev/null +++ b/rule_required_actions_test.go @@ -0,0 +1,205 @@ +package actionlint + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestNewRuleRequiredActions(t *testing.T) { + tests := []struct { + name string + required []RequiredActionRule + want *RuleRequiredActions + }{ + { + name: "nil when no rules", + required: []RequiredActionRule{}, + want: nil, + }, + { + name: "creates rule with requirements", + required: []RequiredActionRule{ + {Action: "actions/checkout", Version: "v3"}, + }, + want: &RuleRequiredActions{ + RuleBase: RuleBase{ + name: "required-actions", + desc: "Checks that required GitHub Actions are used in workflows", + }, + required: []RequiredActionRule{ + {Action: "actions/checkout", Version: "v3"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewRuleRequiredActions(tt.required) + if diff := cmp.Diff(got, tt.want, + cmpopts.IgnoreUnexported(RuleBase{}, RuleRequiredActions{})); diff != "" { + t.Errorf("NewRuleRequiredActions mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestParseActionRef(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantVersion string + }{ + { + name: "valid action reference", + input: "actions/checkout@v3", + wantName: "actions/checkout", + wantVersion: "v3", + }, + { + name: "empty string", + input: "", + wantName: "", + wantVersion: "", + }, + { + name: "docker reference", + input: "docker://alpine:latest", + wantName: "", + wantVersion: "", + }, + { + name: "no version", + input: "actions/checkout", + wantName: "", + wantVersion: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotVersion := parseActionRef(tt.input) + if gotName != tt.wantName || gotVersion != tt.wantVersion { + t.Errorf("parseActionRef(%q) = (%q, %q), want (%q, %q)", + tt.input, gotName, gotVersion, tt.wantName, tt.wantVersion) + } + }) + } +} + +func TestRuleRequiredActions(t *testing.T) { + tests := []struct { + name string + required []RequiredActionRule + workflow *Workflow + wantNilRule bool + wantErrs int + wantMsg string + }{ + { + name: "nil workflow", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}}, + workflow: nil, + wantNilRule: false, + wantErrs: 0, + }, + { + name: "empty workflow", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}}, + workflow: &Workflow{}, + wantNilRule: false, + wantErrs: 1, + wantMsg: `:1:1: required action "actions/checkout" (version "v3") is not used in this workflow [required-actions]`, + }, + { + name: "NoRequiredActions", + required: []RequiredActionRule{}, + workflow: &Workflow{}, + wantNilRule: true, + wantErrs: 0, + }, + { + name: "SingleRequiredAction_Present", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}}, + workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v3"}}}}}}}, + wantNilRule: false, + wantErrs: 0, + }, + { + name: "SingleRequiredAction_Missing_With_Version", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}}, + workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/setup-node@v2"}}}}}}}, + wantNilRule: false, + wantErrs: 1, + wantMsg: `:1:1: required action "actions/checkout" (version "v3") is not used in this workflow [required-actions]`, + }, + { + name: "SingleRequiredAction_Missing_Without_Version", + required: []RequiredActionRule{{Action: "actions/checkout", Version: ""}}, + workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/setup-node@v2"}}}}}}}, + wantNilRule: false, + wantErrs: 1, + wantMsg: `:1:1: required action "actions/checkout" (version "") is not used in this workflow [required-actions]`, + }, + { + name: "SingleRequiredAction_WrongVersion", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}}, + workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v2"}}}}}}}, + wantNilRule: false, + wantErrs: 1, + wantMsg: `:1:1: action "actions/checkout" must use version "v3" but found version "v2" [required-actions]`, + }, + { + name: "MultipleRequiredActions_Present", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}, {Action: "actions/setup-node", Version: "v2"}}, + workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v3"}}}, {Exec: &ExecAction{Uses: &String{Value: "actions/setup-node@v2"}}}}}}}, + wantNilRule: false, + wantErrs: 0, + }, + { + name: "MultipleRequiredActions_MissingOne", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}, {Action: "actions/setup-node", Version: "v2"}}, + workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v3"}}}}}}}, + wantNilRule: false, + wantErrs: 1, + wantMsg: `:1:1: required action "actions/setup-node" (version "v2") is not used in this workflow [required-actions]`, + }, + { + name: "MultipleRequiredActions_WrongVersion", + required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}, {Action: "actions/setup-node", Version: "v2"}}, + workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v2"}}}, {Exec: &ExecAction{Uses: &String{Value: "actions/setup-node@v2"}}}}}}}, + wantNilRule: false, + wantErrs: 1, + wantMsg: `:1:1: action "actions/checkout" must use version "v3" but found version "v2" [required-actions]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule := NewRuleRequiredActions(tt.required) + + if tt.wantNilRule { + if rule != nil { + t.Fatal("Expected nil rule") + } + return + } + + if rule == nil { + t.Fatal("Expected non-nil rule") + } + + rule.VisitWorkflowPre(tt.workflow) + errs := rule.Errs() + if len(errs) != tt.wantErrs { + t.Errorf("got %d errors, want %d", len(errs), tt.wantErrs) + } + if tt.wantMsg != "" && len(errs) > 0 && errs[0].Error() != tt.wantMsg { + t.Errorf("error message mismatch\ngot: %q\nwant: %q", errs[0].Error(), tt.wantMsg) + } + }) + } +}