Skip to content

Commit ddfe181

Browse files
Add required actions rule (and supporting tests) to enforce usage of specific GitHub Actions in workflows
1 parent bbbf2b6 commit ddfe181

4 files changed

+320
-0
lines changed

config.go

+5
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ type Config struct {
6767
// Paths is a "paths" mapping in the configuration file. The keys are glob patterns to match file paths.
6868
// And the values are corresponding configurations applied to the file paths.
6969
Paths map[string]PathConfig `yaml:"paths"`
70+
71+
// RequiredActions specifies which actions must be present in workflows
72+
RequiredActions []RequiredActionRule `yaml:"required-actions"`
73+
74+
7075
}
7176

7277
// PathConfigs returns a list of all PathConfig values matching to the given file path. The path must

linter.go

+6
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,12 @@ func (l *Linter) check(
570570
NewRuleDeprecatedCommands(),
571571
NewRuleIfCond(),
572572
}
573+
574+
// Only add required actions rule if config exists and has required actions
575+
if cfg != nil && len(cfg.RequiredActions) > 0 {
576+
rules = append(rules, NewRuleRequiredActions(cfg.RequiredActions))
577+
}
578+
573579
if l.shellcheck != "" {
574580
r, err := NewRuleShellcheck(l.shellcheck, proc)
575581
if err == nil {

rule_required_actions.go

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Package actionlint provides linting functionality for GitHub Actions workflows.
2+
package actionlint
3+
4+
import (
5+
"fmt"
6+
"strings"
7+
)
8+
9+
// RequiredActionRule represents a rule that enforces the usage of a specific GitHub Action
10+
// with an optional version constraint in workflows.
11+
type RequiredActionRule struct {
12+
Action string `yaml:"Action"` // Name of the required GitHub Action (e.g., "actions/checkout")
13+
Version string `yaml:"Version"` // Optional version constraint (e.g., "v3")
14+
}
15+
16+
// RuleRequiredActions implements a linting rule that checks for the presence and version
17+
// of required GitHub Actions within workflows.
18+
type RuleRequiredActions struct {
19+
RuleBase
20+
required []RequiredActionRule
21+
}
22+
23+
// NewRuleRequiredActions creates a new instance of RuleRequiredActions with the specified
24+
// required actions. Returns nil if no required actions are provided.
25+
func NewRuleRequiredActions(required []RequiredActionRule) *RuleRequiredActions {
26+
if len(required) == 0 {
27+
return nil
28+
}
29+
return &RuleRequiredActions{
30+
RuleBase: RuleBase{
31+
name: "required-actions",
32+
desc: "Checks that required GitHub Actions are used in workflows",
33+
},
34+
required: required,
35+
}
36+
}
37+
38+
// VisitWorkflowPre analyzes the workflow to ensure all required actions are present
39+
// with correct versions. It reports errors for missing or mismatched versions.
40+
func (rule *RuleRequiredActions) VisitWorkflowPre(workflow *Workflow) error {
41+
if workflow == nil {
42+
return nil
43+
}
44+
45+
pos := &Pos{Line: 1, Col: 1}
46+
foundActions := make(map[string]string)
47+
48+
if workflow != nil && len(workflow.Jobs) > 0 {
49+
// Get first job's position
50+
for _, job := range workflow.Jobs {
51+
if job != nil && job.Pos != nil {
52+
pos = job.Pos
53+
break
54+
}
55+
}
56+
57+
// Check steps in all jobs
58+
for _, job := range workflow.Jobs {
59+
if job == nil || len(job.Steps) == 0 {
60+
continue
61+
}
62+
for _, step := range job.Steps {
63+
if step != nil && step.Exec != nil {
64+
if exec, ok := step.Exec.(*ExecAction); ok && exec.Uses != nil {
65+
name, ver := parseActionRef(exec.Uses.Value)
66+
if name != "" {
67+
foundActions[name] = ver
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
75+
// Check required actions
76+
for _, req := range rule.required {
77+
ver, found := foundActions[req.Action]
78+
if !found {
79+
rule.Error(pos, fmt.Sprintf("required action %q (version %q) is not used in this workflow",
80+
req.Action, req.Version))
81+
continue
82+
}
83+
if req.Version != "" && ver != req.Version {
84+
rule.Error(pos, fmt.Sprintf("action %q must use version %q but found version %q",
85+
req.Action, req.Version, ver))
86+
}
87+
}
88+
89+
return nil
90+
}
91+
92+
// parseActionRef extracts the action name and version from a GitHub Action reference.
93+
// Returns empty strings for invalid references like Docker images or malformed strings.
94+
// Example: "actions/checkout@v3" returns ("actions/checkout", "v3")
95+
func parseActionRef(uses string) (name string, version string) {
96+
if uses == "" || !strings.Contains(uses, "/") || strings.HasPrefix(uses, "docker://") {
97+
return "", ""
98+
}
99+
parts := strings.SplitN(uses, "@", 2)
100+
if len(parts) != 2 {
101+
return "", ""
102+
}
103+
return parts[0], parts[1]
104+
}

rule_required_actions_test.go

+205
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package actionlint
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/go-cmp/cmp"
7+
"github.com/google/go-cmp/cmp/cmpopts"
8+
)
9+
10+
func TestNewRuleRequiredActions(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
required []RequiredActionRule
14+
want *RuleRequiredActions
15+
}{
16+
{
17+
name: "nil when no rules",
18+
required: []RequiredActionRule{},
19+
want: nil,
20+
},
21+
{
22+
name: "creates rule with requirements",
23+
required: []RequiredActionRule{
24+
{Action: "actions/checkout", Version: "v3"},
25+
},
26+
want: &RuleRequiredActions{
27+
RuleBase: RuleBase{
28+
name: "required-actions",
29+
desc: "Checks that required GitHub Actions are used in workflows",
30+
},
31+
required: []RequiredActionRule{
32+
{Action: "actions/checkout", Version: "v3"},
33+
},
34+
},
35+
},
36+
}
37+
38+
for _, tt := range tests {
39+
t.Run(tt.name, func(t *testing.T) {
40+
got := NewRuleRequiredActions(tt.required)
41+
if diff := cmp.Diff(got, tt.want,
42+
cmpopts.IgnoreUnexported(RuleBase{}, RuleRequiredActions{})); diff != "" {
43+
t.Errorf("NewRuleRequiredActions mismatch (-got +want):\n%s", diff)
44+
}
45+
})
46+
}
47+
}
48+
49+
func TestParseActionRef(t *testing.T) {
50+
tests := []struct {
51+
name string
52+
input string
53+
wantName string
54+
wantVersion string
55+
}{
56+
{
57+
name: "valid action reference",
58+
input: "actions/checkout@v3",
59+
wantName: "actions/checkout",
60+
wantVersion: "v3",
61+
},
62+
{
63+
name: "empty string",
64+
input: "",
65+
wantName: "",
66+
wantVersion: "",
67+
},
68+
{
69+
name: "docker reference",
70+
input: "docker://alpine:latest",
71+
wantName: "",
72+
wantVersion: "",
73+
},
74+
{
75+
name: "no version",
76+
input: "actions/checkout",
77+
wantName: "",
78+
wantVersion: "",
79+
},
80+
}
81+
82+
for _, tt := range tests {
83+
t.Run(tt.name, func(t *testing.T) {
84+
gotName, gotVersion := parseActionRef(tt.input)
85+
if gotName != tt.wantName || gotVersion != tt.wantVersion {
86+
t.Errorf("parseActionRef(%q) = (%q, %q), want (%q, %q)",
87+
tt.input, gotName, gotVersion, tt.wantName, tt.wantVersion)
88+
}
89+
})
90+
}
91+
}
92+
93+
func TestRuleRequiredActions(t *testing.T) {
94+
tests := []struct {
95+
name string
96+
required []RequiredActionRule
97+
workflow *Workflow
98+
wantNilRule bool
99+
wantErrs int
100+
wantMsg string
101+
}{
102+
{
103+
name: "nil workflow",
104+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}},
105+
workflow: nil,
106+
wantNilRule: false,
107+
wantErrs: 0,
108+
},
109+
{
110+
name: "empty workflow",
111+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}},
112+
workflow: &Workflow{},
113+
wantNilRule: false,
114+
wantErrs: 1,
115+
wantMsg: `:1:1: required action "actions/checkout" (version "v3") is not used in this workflow [required-actions]`,
116+
},
117+
{
118+
name: "NoRequiredActions",
119+
required: []RequiredActionRule{},
120+
workflow: &Workflow{},
121+
wantNilRule: true,
122+
wantErrs: 0,
123+
},
124+
{
125+
name: "SingleRequiredAction_Present",
126+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}},
127+
workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v3"}}}}}}},
128+
wantNilRule: false,
129+
wantErrs: 0,
130+
},
131+
{
132+
name: "SingleRequiredAction_Missing_With_Version",
133+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}},
134+
workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/setup-node@v2"}}}}}}},
135+
wantNilRule: false,
136+
wantErrs: 1,
137+
wantMsg: `:1:1: required action "actions/checkout" (version "v3") is not used in this workflow [required-actions]`,
138+
},
139+
{
140+
name: "SingleRequiredAction_Missing_Without_Version",
141+
required: []RequiredActionRule{{Action: "actions/checkout", Version: ""}},
142+
workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/setup-node@v2"}}}}}}},
143+
wantNilRule: false,
144+
wantErrs: 1,
145+
wantMsg: `:1:1: required action "actions/checkout" (version "") is not used in this workflow [required-actions]`,
146+
},
147+
{
148+
name: "SingleRequiredAction_WrongVersion",
149+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}},
150+
workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v2"}}}}}}},
151+
wantNilRule: false,
152+
wantErrs: 1,
153+
wantMsg: `:1:1: action "actions/checkout" must use version "v3" but found version "v2" [required-actions]`,
154+
},
155+
{
156+
name: "MultipleRequiredActions_Present",
157+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}, {Action: "actions/setup-node", Version: "v2"}},
158+
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"}}}}}}},
159+
wantNilRule: false,
160+
wantErrs: 0,
161+
},
162+
{
163+
name: "MultipleRequiredActions_MissingOne",
164+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}, {Action: "actions/setup-node", Version: "v2"}},
165+
workflow: &Workflow{Jobs: map[string]*Job{"build": {Steps: []*Step{{Exec: &ExecAction{Uses: &String{Value: "actions/checkout@v3"}}}}}}},
166+
wantNilRule: false,
167+
wantErrs: 1,
168+
wantMsg: `:1:1: required action "actions/setup-node" (version "v2") is not used in this workflow [required-actions]`,
169+
},
170+
{
171+
name: "MultipleRequiredActions_WrongVersion",
172+
required: []RequiredActionRule{{Action: "actions/checkout", Version: "v3"}, {Action: "actions/setup-node", Version: "v2"}},
173+
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"}}}}}}},
174+
wantNilRule: false,
175+
wantErrs: 1,
176+
wantMsg: `:1:1: action "actions/checkout" must use version "v3" but found version "v2" [required-actions]`,
177+
},
178+
}
179+
180+
for _, tt := range tests {
181+
t.Run(tt.name, func(t *testing.T) {
182+
rule := NewRuleRequiredActions(tt.required)
183+
184+
if tt.wantNilRule {
185+
if rule != nil {
186+
t.Fatal("Expected nil rule")
187+
}
188+
return
189+
}
190+
191+
if rule == nil {
192+
t.Fatal("Expected non-nil rule")
193+
}
194+
195+
rule.VisitWorkflowPre(tt.workflow)
196+
errs := rule.Errs()
197+
if len(errs) != tt.wantErrs {
198+
t.Errorf("got %d errors, want %d", len(errs), tt.wantErrs)
199+
}
200+
if tt.wantMsg != "" && len(errs) > 0 && errs[0].Error() != tt.wantMsg {
201+
t.Errorf("error message mismatch\ngot: %q\nwant: %q", errs[0].Error(), tt.wantMsg)
202+
}
203+
})
204+
}
205+
}

0 commit comments

Comments
 (0)