Skip to content

Commit b1d6ae6

Browse files
committed
make error message for cycle in needs: deterministic
1 parent 91a1563 commit b1d6ae6

File tree

7 files changed

+74
-22
lines changed

7 files changed

+74
-22
lines changed

docs/checks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,7 @@ jobs:
11241124
Output:
11251125

11261126
```
1127-
test.yaml:3:3: cyclic dependencies in "needs" configurations of jobs are detected. detected cycle is "prepare" -> "build" -> "install" -> "prepare" [job-needs]
1127+
test.yaml:3:3: cyclic dependencies in "needs" job configurations are detected. detected cycle is "prepare" -> "build" -> "install" -> "prepare" [job-needs]
11281128
|
11291129
3 | prepare:
11301130
| ^~~~~~~~

rule_job_needs.go

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package actionlint
22

33
import (
4-
"fmt"
4+
"strconv"
55
"strings"
66
)
77

@@ -44,7 +44,7 @@ func NewRuleJobNeeds() *RuleJobNeeds {
4444
}
4545
}
4646

47-
func contains(heystack []string, needle string) bool {
47+
func contains[T comparable](heystack []T, needle T) bool {
4848
for _, s := range heystack {
4949
if s == needle {
5050
return true
@@ -107,47 +107,61 @@ func (rule *RuleJobNeeds) VisitWorkflowPost(n *Workflow) error {
107107
return nil
108108
}
109109

110-
if edge := detectCyclic(rule.nodes); edge != nil {
111-
edges := map[string]string{}
112-
edges[edge.from.id] = edge.to.id
113-
collectCyclic(edge.to, edges)
110+
// Note: Only the first cycle can be detected even if there are multiple cycles in "needs:" configurations.
111+
if edge := detectFirstCycle(rule.nodes); edge != nil {
112+
edges := map[*jobNode]*jobNode{}
113+
edges[edge.from] = edge.to
114+
collectCycle(edge.to, edges)
115+
116+
// Start cycle from the smallest position to make the error message deterministic
117+
start := edge.from
118+
for n := range edges {
119+
if n.pos.IsBefore(start.pos) {
120+
start = n
121+
}
122+
}
114123

115-
desc := make([]string, 0, len(edges))
116-
for from, to := range edges {
117-
desc = append(desc, fmt.Sprintf("%q -> %q", from, to))
124+
var msg strings.Builder
125+
msg.WriteString("cyclic dependencies in \"needs\" job configurations are detected. detected cycle is ")
126+
127+
msg.WriteString(strconv.Quote(start.id))
128+
from, to := start, edges[start]
129+
for {
130+
msg.WriteString(" -> ")
131+
msg.WriteString(strconv.Quote(to.id))
132+
from, to = to, edges[to]
133+
if from == start {
134+
break
135+
}
118136
}
119137

120-
rule.Errorf(
121-
edge.from.pos,
122-
"cyclic dependencies in \"needs\" configurations of jobs are detected. detected cycle is %s",
123-
strings.Join(desc, ", "),
124-
)
138+
rule.Error(start.pos, msg.String())
125139
}
126140

127141
return nil
128142
}
129143

130-
func collectCyclic(src *jobNode, edges map[string]string) bool {
144+
func collectCycle(src *jobNode, edges map[*jobNode]*jobNode) bool {
131145
for _, dest := range src.resolved {
132146
if dest.status != nodeStatusActive {
133147
continue
134148
}
135-
edges[src.id] = dest.id
136-
if _, ok := edges[dest.id]; ok {
149+
edges[src] = dest
150+
if _, ok := edges[dest]; ok {
137151
return true
138152
}
139-
if collectCyclic(dest, edges) {
153+
if collectCycle(dest, edges) {
140154
return true
141155
}
142-
delete(edges, src.id)
156+
delete(edges, src)
143157
}
144158
return false
145159
}
146160

147161
// Detect cyclic dependencies
148162
// https://inzkyk.xyz/algorithms/depth_first_search/detecting_cycles/
149163

150-
func detectCyclic(nodes map[string]*jobNode) *edge {
164+
func detectFirstCycle(nodes map[string]*jobNode) *edge {
151165
for _, v := range nodes {
152166
if v.status == nodeStatusNew {
153167
if e := detectCyclicNode(v); e != nil {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test.yaml:4:3: cyclic dependencies in "needs" job configurations are detected. detected cycle is "from" -> "to" -> "from" [job-needs]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
on: push
2+
jobs:
3+
# Minimal
4+
from:
5+
needs: [to]
6+
runs-on: ubuntu-latest
7+
steps:
8+
- run: echo 'from'
9+
to:
10+
needs: [from]
11+
runs-on: ubuntu-latest
12+
steps:
13+
- run: echo 'to'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test.yaml:4:3: job "a" needs job "e" which does not exist in this workflow [job-needs]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
on: push
2+
jobs:
3+
# Order of jobs is random
4+
a:
5+
needs: [b, e]
6+
runs-on: ubuntu-latest
7+
steps:
8+
- run: echo 'a'
9+
c:
10+
needs: [d]
11+
runs-on: ubuntu-latest
12+
steps:
13+
- run: echo 'd'
14+
d:
15+
needs: [a]
16+
runs-on: ubuntu-latest
17+
steps:
18+
- run: echo 'a'
19+
b:
20+
needs: [c]
21+
runs-on: ubuntu-latest
22+
steps:
23+
- run: echo 'b'
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
/test\.yaml:\d+:\d+: cyclic dependencies in "needs" configurations of jobs are detected\. detected cycle is ".+" -> ".+", ".+" -> ".+", ".+" -> ".+" \[job-needs\]/
1+
test.yaml:3:3: cyclic dependencies in "needs" job configurations are detected. detected cycle is "prepare" -> "build" -> "install" -> "prepare" [job-needs]

0 commit comments

Comments
 (0)