Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add required actions rule (and supporting tests) to enforce usage of specific GitHub Actions in workflows #474

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
})
}
}