1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strconv"
10 "strings"
11
12 "bnema.dev/ermite-actions/expr"
13 "bnema.dev/ermite-actions/template"
14)
15
16// Strategy is an evaluated "strategy" key.
17type Strategy struct {
18 // FailFast is true unless the file says otherwise.
19 FailFast bool
20 // MaxParallel is the limit of concurrent jobs, 0 for no limit.
21 MaxParallel int
22 // HasMatrix reports whether the strategy has a matrix. Without one,
23 // Combos holds a single empty combination: the job runs once.
24 HasMatrix bool
25 // Combos are the jobs, in the order GitHub creates them.
26 Combos []MatrixCombo
27}
28
29// ParseStrategy reads an evaluated strategy value and expands its matrix,
30// see [ExpandMatrix] for the rules and limits.
31func ParseStrategy(ctx context.Context, v expr.Value) (*Strategy, error) {
32 s := &Strategy{FailFast: true}
33 var matrix expr.Value
34 switch d := v.(type) {
35 case nil, expr.Null:
36 case *expr.Dictionary:
37 if d == nil {
38 break
39 }
40 for _, p := range d.Pairs() {
41 switch strings.ToLower(p.Key) {
42 case "fail-fast":
43 b, err := strategyBool(p.Value)
44 if err != nil {
45 return nil, fmt.Errorf("unexpected value for 'fail-fast': %w", err)
46 }
47 s.FailFast = b
48 case "max-parallel":
49 n, err := strategyInt(p.Value)
50 if err != nil {
51 return nil, fmt.Errorf("unexpected value for 'max-parallel': %w", err)
52 }
53 s.MaxParallel = n
54 case "matrix":
55 matrix = p.Value
56 }
57 }
58 default:
59 return nil, errors.New("the strategy must be a mapping")
60 }
61 combos, has, err := buildMatrix(ctx, matrix)
62 if err != nil {
63 return nil, err
64 }
65 s.Combos, s.HasMatrix = combos, has
66 return s, nil
67}
68
69// Context returns the "strategy" context of job number index (from 0).
70func (s *Strategy) Context(index int) *expr.Dictionary {
71 d := expr.NewDictionary()
72 d.Add("fail-fast", expr.Boolean(s.FailFast))
73 d.Add("job-index", expr.Number(index))
74 d.Add("job-total", expr.Number(len(s.Combos)))
75 max := s.MaxParallel
76 if max == 0 {
77 max = len(s.Combos)
78 }
79 d.Add("max-parallel", expr.Number(max))
80 return d
81}
82
83func strategyBool(v expr.Value) (bool, error) {
84 switch x := v.(type) {
85 case expr.Boolean:
86 return bool(x), nil
87 case expr.String:
88 switch strings.ToLower(string(x)) {
89 case "true":
90 return true, nil
91 case "false":
92 return false, nil
93 }
94 }
95 return false, fmt.Errorf("expected a boolean but got %s", kindName(v))
96}
97
98func strategyInt(v expr.Value) (int, error) {
99 var f float64
100 switch x := v.(type) {
101 case expr.Number:
102 f = float64(x)
103 case expr.String:
104 n, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64)
105 if err != nil {
106 return 0, fmt.Errorf("expected a number but got '%s'", string(x))
107 }
108 f = n
109 default:
110 return 0, fmt.Errorf("expected a number but got %s", kindName(v))
111 }
112 if f != float64(int(f)) || f < 1 || f > 1<<31 {
113 return 0, errors.New("expected a whole number greater than 0")
114 }
115 return int(f), nil
116}
117
118func kindName(v expr.Value) string {
119 if v == nil {
120 return expr.KindNull.String()
121 }
122 return v.Kind().String()
123}
124
125// EvaluateStrategy evaluates the job's strategy token against env and
126// expands its matrix. env must provide the contexts the schema allows in
127// "strategy": github, inputs, vars and needs. A job without a strategy gives
128// a Strategy with one empty combination.
129func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) {
130 if j.Strategy == nil {
131 return ParseStrategy(ctx, nil)
132 }
133 v, err := template.Evaluate(ctx, j.Strategy, env)
134 if err != nil {
135 return nil, err
136 }
137 st, err := ParseStrategy(ctx, v)
138 var limit *configurationLimitError
139 if errors.As(err, &limit) {
140 return nil, &configurationLimitError{Job: j.ID, Count: limit.Count}
141 }
142 return st, err
143}
144
145// MatrixEnv returns env with the "matrix" and "strategy" contexts of
146// combination index added, for evaluating the parts of a job that may use
147// them: runs-on, name, env, container, services, steps... It fails when index
148// is out of range.
149func (s *Strategy) MatrixEnv(env template.Env, index int) (template.Env, error) {
150 if index < 0 || index >= len(s.Combos) {
151 return env, fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos))
152 }
153 out := env
154 out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2)
155 for k, v := range env.Contexts {
156 out.Contexts[k] = v
157 }
158 out.Contexts["matrix"] = s.Combos[index].Values
159 out.Contexts["strategy"] = s.Context(index)
160 return out, nil
161}
162
163// DisplayName evaluates the job's name for combination index of its
164// strategy: the name token (or the job id) with the matrix values added in
165// parentheses, unless the name is set and reads the matrix context, in which
166// case it is used as is. Null and empty values are not listed. env must
167// already include the matrix and strategy contexts, see [Strategy.MatrixEnv].
168//
169// Recorded on github.com (matrix-job-name): "default-name (a, 1)", a custom
170// name using the matrix as is, and a custom name that does not read the
171// matrix followed by the values ("same name for every leg (1)").
172func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) {
173 if index < 0 || index >= len(s.Combos) {
174 return "", fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos))
175 }
176 name, err := template.EvaluateString(ctx, j.Name, env)
177 if err != nil {
178 return "", err
179 }
180 if !s.HasMatrix || (j.HasName && usesMatrix(j.Name)) {
181 return name, nil
182 }
183 return s.Combos[index].DisplayName(name), nil
184}
185
186// usesMatrix reports whether an expression in t reads the matrix context.
187func usesMatrix(t template.Token) bool {
188 found := false
189 template.Walk(t, func(x template.Token) bool {
190 if be, ok := x.(*template.BasicExpression); ok && be.UsesContext("matrix") {
191 found = true
192 }
193 return !found
194 })
195 return found
196}