Skip to content

Commit

Permalink
Add required actions rule (and supporting tests) to enforce usage of …
Browse files Browse the repository at this point in the history
…specific GitHub Actions in workflows
  • Loading branch information
chrisreddington committed Nov 11, 2024
1 parent bbbf2b6 commit ddfe181
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 0 deletions.
5 changes: 5 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions linter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions rule_required_actions.go
Original file line number Diff line number Diff line change
@@ -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]
}
205 changes: 205 additions & 0 deletions rule_required_actions_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}

0 comments on commit ddfe181

Please sign in to comment.