1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6 "context"
7 "fmt"
8 "slices"
9 "strings"
10
11 "bnema.dev/ermite-actions/expr"
12 "bnema.dev/ermite-actions/template"
13)
14
15// StatusFuncs is the state the status functions of conditions report:
16// success(), failure(), cancelled() and always().
17type StatusFuncs struct {
18 // Success is the result of success(): true while nothing before has
19 // failed or been cancelled.
20 Success bool
21 // Failure is the result of failure().
22 Failure bool
23 // Cancelled is the result of cancelled().
24 Cancelled bool
25 // always() is true, whatever the state.
26}
27
28// Funcs returns the implementations to put in [template.Env.Funcs]. The
29// functions ignore their arguments.
30func (s StatusFuncs) Funcs() map[string]expr.Func {
31 fixed := func(b bool) expr.Func {
32 return func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(b), nil }
33 }
34 return map[string]expr.Func{
35 "success": fixed(s.Success),
36 "failure": fixed(s.Failure),
37 "cancelled": fixed(s.Cancelled),
38 "always": fixed(true),
39 }
40}
41
42// evaluateIf evaluates a condition with the status functions added to env and
43// converts the result to a boolean the way GitHub does (see expr.Truthy).
44func evaluateIf(ctx context.Context, cond *template.BasicExpression, env template.Env, st StatusFuncs) (bool, error) {
45 out := env
46 out.Funcs = make(map[string]expr.Func, len(env.Funcs)+4)
47 for k, f := range env.Funcs {
48 out.Funcs[k] = f
49 }
50 for k, f := range st.Funcs() {
51 out.Funcs[k] = f
52 }
53 v, err := template.EvaluateExpression(ctx, cond, out)
54 if err != nil {
55 return false, err
56 }
57 return expr.Truthy(v), nil
58}
59
60// EvaluateIf evaluates the job's condition. env must provide the contexts
61// the schema allows in a job's "if": github, inputs, vars and needs.
62func (j *Job) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) {
63 return evaluateIf(ctx, j.If, env, st)
64}
65
66// EvaluateIf evaluates the step's condition. env must provide the contexts
67// the schema allows in a step's "if" and may provide hashFiles.
68func (s *Step) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) {
69 return evaluateIf(ctx, s.If, env, st)
70}
71
72// EvaluateRunsOn evaluates the job's runs-on and returns the labels and the
73// runner group. runs-on may be a string, a list of strings, or a mapping with
74// "group" and "labels" (a string or a list), each part possibly an
75// expression. A job that calls a reusable workflow has no runs-on.
76//
77// The checks are those of the reference's converter: a group is a name, or
78// "org/name", "organization/name", "ent/name" or "enterprise/name".
79func (j *Job) EvaluateRunsOn(ctx context.Context, env template.Env) (labels []string, group string, err error) {
80 if j.RunsOn == nil {
81 return nil, "", fmt.Errorf("job '%s' has no runs-on", j.ID)
82 }
83 v, err := template.Evaluate(ctx, j.RunsOn, env)
84 if err != nil {
85 return nil, "", err
86 }
87 fail := func(format string, a ...any) ([]string, string, error) {
88 return nil, "", &template.Error{File: j.RunsOn.File(), Pos: j.RunsOn.Pos(), Raw: fmt.Sprintf(format, a...)}
89 }
90 switch v := v.(type) {
91 case *expr.Dictionary:
92 for _, p := range v.Pairs() {
93 switch strings.ToLower(p.Key) {
94 case "group":
95 g, ok := p.Value.(expr.String)
96 if !ok {
97 return fail("Unexpected type '%s' encountered while reading 'job runs-on group name'. The type 'StringToken' was expected.", tokenKind(p.Value))
98 }
99 if msg := checkGroup(string(g)); msg != "" {
100 return fail("%s", msg)
101 }
102 group = string(g)
103 case "labels":
104 l, bad := labelList(p.Value)
105 if bad != "" {
106 return fail("%s", bad)
107 }
108 labels = append(labels, l...)
109 }
110 }
111 return labels, group, nil
112 }
113 labels, bad := labelList(v)
114 if bad != "" {
115 return fail("%s", bad)
116 }
117 return labels, "", nil
118}
119
120func tokenKind(v expr.Value) string {
121 switch v.(type) {
122 case expr.String:
123 return template.KindString.String()
124 case expr.Number:
125 return template.KindNumber.String()
126 case expr.Boolean:
127 return template.KindBoolean.String()
128 case *expr.Array:
129 return template.KindSequence.String()
130 case *expr.Dictionary:
131 return template.KindMapping.String()
132 }
133 return template.KindNull.String()
134}
135
136// labelList reads a string or a list of strings. It returns the problem as a
137// message.
138func labelList(v expr.Value) ([]string, string) {
139 switch v := v.(type) {
140 case expr.String:
141 return []string{string(v)}, ""
142 case *expr.Array:
143 var out []string
144 for _, it := range v.Items() {
145 s, ok := it.(expr.String)
146 if !ok {
147 return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(it))
148 }
149 out = append(out, string(s))
150 }
151 return out, ""
152 }
153 return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(v))
154}
155
156// checkGroup returns the reference's message for an invalid runner group.
157func checkGroup(group string) string {
158 names := strings.Split(group, "/")
159 switch len(names) {
160 case 1:
161 return ""
162 case 2:
163 switch names[0] {
164 case "org", "organization", "ent", "enterprise":
165 default:
166 return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
167 }
168 if names[1] == "" {
169 return fmt.Sprintf("Invalid runs-on group name '%s'.", group)
170 }
171 return ""
172 }
173 return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
174}
175
176// JobLevels groups the jobs by dependency depth: the first level holds the
177// jobs without needs, and every later job is in the level after the last of
178// its dependencies. Within a level the jobs keep the order of the file. A
179// job whose needs name an unknown job, or that is part of a cycle, is left
180// out; Parse rejects such workflows, so this only happens for a Workflow
181// built by hand.
182func (w *Workflow) JobLevels() [][]*Job {
183 level := make(map[string]int, len(w.Jobs))
184 var levels [][]*Job
185 placed := 0
186 for placed < len(w.Jobs) {
187 progressed := false
188 for _, j := range w.Jobs {
189 if _, done := level[j.ID]; done {
190 continue
191 }
192 depth, ready := 0, true
193 for _, n := range j.Needs {
194 l, ok := level[n]
195 if !ok {
196 ready = false
197 break
198 }
199 depth = max(depth, l+1)
200 }
201 if !ready {
202 continue
203 }
204 level[j.ID] = depth
205 for len(levels) <= depth {
206 levels = append(levels, nil)
207 }
208 levels[depth] = append(levels[depth], j)
209 placed++
210 progressed = true
211 }
212 if !progressed {
213 break
214 }
215 }
216 // A job placed in a later pass of the file may belong to an earlier level
217 // than one placed before it; restore file order inside each level.
218 pos := make(map[*Job]int, len(w.Jobs))
219 for i, j := range w.Jobs {
220 pos[j] = i
221 }
222 for _, l := range levels {
223 slices.SortFunc(l, func(a, b *Job) int { return pos[a] - pos[b] })
224 }
225 return levels
226}
227
228// Ancestors returns the ids of every job the job with the given id depends on,
229// directly or through other jobs, in the order of the file. It is empty for a
230// job without needs and nil for an unknown id.
231//
232// GitHub's default condition of a job, success(), looks at this whole chain
233// and not only at the direct needs: a job that needs a job which ran through
234// always() after a failure is skipped, although its only direct need
235// succeeded. Recorded (needs-failed-dependency): chain-after-always needs
236// with-always, which needs broken; broken failed, with-always (if: always())
237// succeeded, and chain-after-always was skipped. Likewise failure() is true
238// when any ancestor failed, as documented.
239func (w *Workflow) Ancestors(id string) []string {
240 start := w.Job(id)
241 if start == nil {
242 return nil
243 }
244 seen := map[string]bool{id: true}
245 stack := slices.Clone(start.Needs)
246 for len(stack) > 0 {
247 n := stack[len(stack)-1]
248 stack = stack[:len(stack)-1]
249 if seen[n] {
250 continue
251 }
252 seen[n] = true
253 if j := w.Job(n); j != nil {
254 stack = append(stack, j.Needs...)
255 }
256 }
257 delete(seen, id)
258 out := []string{}
259 for _, j := range w.Jobs {
260 if seen[j.ID] {
261 out = append(out, j.ID)
262 }
263 }
264 return out
265}