-
Notifications
You must be signed in to change notification settings - Fork 164
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add required actions rule (and supporting tests) to enforce usage of …
…specific GitHub Actions in workflows
- Loading branch information
1 parent
bbbf2b6
commit ddfe181
Showing
4 changed files
with
320 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |