Skip to content

Commit 43f7b04

Browse files
committed
fix data race on loading config by multiple goroutines (fix #333)
1 parent c29244f commit 43f7b04

File tree

9 files changed

+157
-51
lines changed

9 files changed

+157
-51
lines changed

config.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package actionlint
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"strings"
78

89
"gopkg.in/yaml.v3"
@@ -41,6 +42,24 @@ func ReadConfigFile(path string) (*Config, error) {
4142
return parseConfig(b, path)
4243
}
4344

45+
// loadRepoConfig reads config file from the repository's .github/actionlint.yml or
46+
// .github/actionlint.yaml.
47+
func loadRepoConfig(root string) (*Config, error) {
48+
for _, f := range []string{"actionlint.yaml", "actionlint.yml"} {
49+
path := filepath.Join(root, ".github", f)
50+
b, err := os.ReadFile(path)
51+
if err != nil {
52+
continue // file does not exist
53+
}
54+
cfg, err := parseConfig(b, path)
55+
if err != nil {
56+
return nil, err
57+
}
58+
return cfg, nil
59+
}
60+
return nil, nil
61+
}
62+
4463
func writeDefaultConfigFile(path string) error {
4564
b := []byte(`self-hosted-runner:
4665
# Labels of self-hosted runner in array of strings.

linter.go

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,10 @@ func (l *Linter) debugWriter() io.Writer {
216216
func (l *Linter) GenerateDefaultConfig(dir string) error {
217217
l.log("Generating default actionlint.yaml in repository:", dir)
218218

219-
p := l.projects.At(dir)
219+
p, err := l.projects.At(dir)
220+
if err != nil {
221+
return err
222+
}
220223
if p == nil {
221224
return errors.New("project is not found. check current project is initialized as Git repository and \".github/workflows\" directory exists")
222225
}
@@ -240,14 +243,17 @@ func (l *Linter) GenerateDefaultConfig(dir string) error {
240243
func (l *Linter) LintRepository(dir string) ([]*Error, error) {
241244
l.log("Linting all workflow files in repository:", dir)
242245

243-
proj := l.projects.At(dir)
244-
if proj == nil {
246+
p, err := l.projects.At(dir)
247+
if err != nil {
248+
return nil, err
249+
}
250+
if p == nil {
245251
return nil, fmt.Errorf("no project was found in any parent directories of %q. check workflows directory is put correctly in your Git repository", dir)
246252
}
247253

248-
l.log("Detected project:", proj.RootDir())
249-
wd := proj.WorkflowsDir()
250-
return l.LintDir(wd, proj)
254+
l.log("Detected project:", p.RootDir())
255+
wd := p.WorkflowsDir()
256+
return l.LintDir(wd, p)
251257
}
252258

253259
// LintDir lints all YAML workflow files in the given directory recursively.
@@ -316,14 +322,18 @@ func (l *Linter) LintFiles(filepaths []string, project *Project) ([]*Error, erro
316322
for i := range ws {
317323
// Each element of ws is accessed by single goroutine so mutex is unnecessary
318324
w := &ws[i]
319-
p := project
320-
if p == nil {
325+
proj := project
326+
if proj == nil {
321327
// This method modifies state of l.projects so it cannot be called in parallel.
322328
// Before entering goroutine, resolve project instance.
323-
p = l.projects.At(w.path)
329+
p, err := l.projects.At(w.path)
330+
if err != nil {
331+
return nil, err
332+
}
333+
proj = p
324334
}
325-
ac := acf.GetCache(p) // #173
326-
rwc := rwcf.GetCache(p)
335+
ac := acf.GetCache(proj) // #173
336+
rwc := rwcf.GetCache(proj)
327337

328338
eg.Go(func() error {
329339
// Bound concurrency on reading files to avoid "too many files to open" error (issue #3)
@@ -339,7 +349,7 @@ func (l *Linter) LintFiles(filepaths []string, project *Project) ([]*Error, erro
339349
w.path = r // Use relative path if possible
340350
}
341351
}
342-
errs, err := l.check(w.path, src, p, proc, ac, rwc)
352+
errs, err := l.check(w.path, src, proj, proc, ac, rwc)
343353
if err != nil {
344354
return fmt.Errorf("fatal error while checking %s: %w", w.path, err)
345355
}
@@ -389,7 +399,11 @@ func (l *Linter) LintFiles(filepaths []string, project *Project) ([]*Error, erro
389399
// parameter can be nil. In the case, the project is detected from the given path.
390400
func (l *Linter) LintFile(path string, project *Project) ([]*Error, error) {
391401
if project == nil {
392-
project = l.projects.At(path)
402+
p, err := l.projects.At(path)
403+
if err != nil {
404+
return nil, err
405+
}
406+
project = p
393407
}
394408

395409
src, err := os.ReadFile(path)
@@ -428,7 +442,11 @@ func (l *Linter) LintFile(path string, project *Project) ([]*Error, error) {
428442
func (l *Linter) Lint(path string, content []byte, project *Project) ([]*Error, error) {
429443
if project == nil && path != "<stdin>" {
430444
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
431-
project = l.projects.At(path)
445+
p, err := l.projects.At(path)
446+
if err != nil {
447+
return nil, err
448+
}
449+
project = p
432450
}
433451
}
434452
proc := newConcurrentProcess(runtime.NumCPU())
@@ -471,13 +489,10 @@ func (l *Linter) check(
471489

472490
var cfg *Config
473491
if l.defaultConfig != nil {
492+
// `-config-file` option has higher prioritiy than repository config file
474493
cfg = l.defaultConfig
475494
} else if project != nil {
476-
c, err := project.LoadConfig()
477-
if err != nil {
478-
return nil, err
479-
}
480-
cfg = c
495+
cfg = project.Config()
481496
}
482497
if cfg != nil {
483498
l.debug("Config: %#v", cfg)

project.go

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,24 @@ func absPath(path string) string {
2121

2222
// findProject creates new Project instance by finding a project which the given path belongs to.
2323
// A project must be a Git repository and have ".github/workflows" directory.
24-
func findProject(path string) *Project {
24+
func findProject(path string) (*Project, error) {
2525
d := absPath(path)
2626
for {
2727
w := filepath.Join(d, ".github", "workflows")
2828
if s, err := os.Stat(w); err == nil && s.IsDir() {
2929
g := filepath.Join(d, ".git")
3030
if _, err := os.Stat(g); err == nil { // Note: .git may be a file
31-
return &Project{root: d}
31+
c, err := loadRepoConfig(d)
32+
if err != nil {
33+
return nil, err
34+
}
35+
return &Project{root: d, config: c}, nil
3236
}
3337
}
3438

3539
p := filepath.Dir(d)
3640
if p == d {
37-
return nil
41+
return nil, nil
3842
}
3943
d = p
4044
}
@@ -58,28 +62,12 @@ func (p *Project) Knows(path string) bool {
5862
return strings.HasPrefix(absPath(path), p.root)
5963
}
6064

61-
// LoadConfig returns config object of the GitHub project repository. The config file is read from
62-
// ".github/actionlint.yaml" or ".github/actionlint.yml".
63-
func (p *Project) LoadConfig() (*Config, error) {
64-
if p.config != nil {
65-
return p.config, nil
66-
}
67-
68-
for _, f := range []string{"actionlint.yaml", "actionlint.yml"} {
69-
path := filepath.Join(p.root, ".github", f)
70-
b, err := os.ReadFile(path)
71-
if err != nil {
72-
continue // file does not exist
73-
}
74-
cfg, err := parseConfig(b, path)
75-
if err != nil {
76-
return nil, err
77-
}
78-
p.config = cfg
79-
return cfg, nil
80-
}
81-
82-
return nil, nil // not found
65+
// Config returns config object of the GitHub project repository. The config file was read from
66+
// ".github/actionlint.yaml" or ".github/actionlint.yml" when this Project instance was created.
67+
// When no config was found, this method returns nil.
68+
func (p *Project) Config() *Config {
69+
// Note: Calling this method must be thread safe (#333)
70+
return p.config
8371
}
8472

8573
// Projects represents set of projects. It caches Project instances which was created previously
@@ -95,17 +83,20 @@ func NewProjects() *Projects {
9583

9684
// At returns the Project instance which the path belongs to. It returns nil if no project is found
9785
// from the path.
98-
func (ps *Projects) At(path string) *Project {
86+
func (ps *Projects) At(path string) (*Project, error) {
9987
for _, p := range ps.known {
10088
if p.Knows(path) {
101-
return p
89+
return p, nil
10290
}
10391
}
10492

105-
p := findProject(path)
93+
p, err := findProject(path)
94+
if err != nil {
95+
return nil, err
96+
}
10697
if p != nil {
10798
ps.known = append(ps.known, p)
10899
}
109100

110-
return p
101+
return p, nil
111102
}

project_test.go

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package actionlint
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78
)
89

@@ -57,15 +58,21 @@ func TestProjectsFindProjectFromPath(t *testing.T) {
5758
},
5859
} {
5960
t.Run(tc.what, func(t *testing.T) {
60-
p := ps.At(tc.path)
61+
p, err := ps.At(tc.path)
62+
if err != nil {
63+
t.Fatal(err)
64+
}
6165

6266
r := p.RootDir()
6367
if r != abs {
6468
t.Fatalf("root directory of project %v should be %q but got %q", p, abs, r)
6569
}
6670

6771
// Result should be cached
68-
p2 := ps.At(tc.path)
72+
p2, err := ps.At(tc.path)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
6976
if p != p2 {
7077
t.Fatalf("project %v is not cached. New project is %v. %p v.s. %p", p, p2, p, p2)
7178
}
@@ -83,8 +90,60 @@ func TestProjectsDoesNotFindProjectFromOutside(t *testing.T) {
8390

8491
outside := filepath.Join(d, "..")
8592
ps := NewProjects()
86-
p := ps.At(outside)
93+
p, err := ps.At(outside)
94+
if err != nil {
95+
t.Fatal(err)
96+
}
8797
if p != nil && p.RootDir() == abs {
8898
t.Fatalf("project %v is detected from outside of the project %q", p, outside)
8999
}
90100
}
101+
102+
func TestProjectsLoadingProjectConfig(t *testing.T) {
103+
d := filepath.Join("testdata", "config", "projects", "ok")
104+
testEnsureDotGitDir(d)
105+
ps := NewProjects()
106+
p, err := ps.At(d)
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
if p == nil {
111+
t.Fatal("project was not found at", d)
112+
}
113+
if c := p.Config(); c == nil {
114+
t.Fatal("config was not found for directory", d)
115+
}
116+
}
117+
118+
func TestProjectsLoadingNoProjectConfig(t *testing.T) {
119+
d := filepath.Join("testdata", "config", "projects", "none")
120+
testEnsureDotGitDir(d)
121+
ps := NewProjects()
122+
p, err := ps.At(d)
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
if p == nil {
127+
t.Fatal("project was not found at", d)
128+
}
129+
if c := p.Config(); c != nil {
130+
t.Fatal("config was found for directory", d)
131+
}
132+
}
133+
134+
func TestProjectsLoadingBrokenProjectConfig(t *testing.T) {
135+
want := "could not parse config file"
136+
d := filepath.Join("testdata", "config", "projects", "err")
137+
testEnsureDotGitDir(d)
138+
ps := NewProjects()
139+
p, err := ps.At(d)
140+
if err == nil {
141+
t.Fatalf("wanted error %q but have no error", want)
142+
}
143+
if p != nil {
144+
t.Fatal("project was returned though getting config failed", p)
145+
}
146+
if msg := err.Error(); !strings.Contains(msg, want) {
147+
t.Fatalf("wanted error %q but have error %q", want, msg)
148+
}
149+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
self-hosted-runner: 42
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
on: push
2+
jobs:
3+
test:
4+
runs-on: ubuntu-latest
5+
steps:
6+
- run: echo
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
on: push
2+
jobs:
3+
test:
4+
runs-on: ubuntu-latest
5+
steps:
6+
- run: echo
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
self-hosted-runner:
2+
labels: []
3+
config-variables: null
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
on: push
2+
jobs:
3+
test:
4+
runs-on: ubuntu-latest
5+
steps:
6+
- run: echo

0 commit comments

Comments
 (0)