detached fd4d905

feat(workflow): add strategy parsing and matrix expansion

commit
fd4d9050b7ffd80522617982eaaa7cc3f0eb4440
author
brice · · 2026-10-12 15:56 UTC
committer
brice · · 2026-10-12 16:17 UTC
parent
028f5a1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +673 -0
A workflow/matrix.go +432 -0
@@ -0,0 +1,432 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "strconv"
9+ "strings"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+// MaxMatrixJobs is the number of jobs a matrix may produce.
15+const MaxMatrixJobs = 256
16+
17+// maxMatrixProduct bounds the size of the cross product before exclusions,
18+// so that a matrix of many large vectors fails fast instead of looping.
19+// GitHub only limits the final number of jobs.
20+const maxMatrixProduct = 1 << 20
21+
22+// MatrixCombo is one job of a matrix.
23+type MatrixCombo struct {
24+ // Values is the "matrix" context of the job: the vector values in the
25+ // order of the keys, then the keys added by include entries in the order
26+ // they were first added.
27+ Values *expr.Dictionary
28+ // Segments are the scalar values of Values, in order, shown in the name
29+ // of the job: "build (ubuntu-latest, 14)". Arrays and mappings are left
30+ // out.
31+ Segments []string
32+}
33+
34+// DisplayName returns the name of the job: base alone when there are no
35+// segments, else base followed by the segments in parentheses.
36+func (c MatrixCombo) DisplayName(base string) string {
37+ if len(c.Segments) == 0 {
38+ return base
39+ }
40+ return base + " (" + strings.Join(c.Segments, ", ") + ")"
41+}
42+
43+// Strategy is an evaluated "strategy" key.
44+type Strategy struct {
45+ // FailFast is true unless the file says otherwise.
46+ FailFast bool
47+ // MaxParallel is the limit of concurrent jobs, 0 for no limit.
48+ MaxParallel int
49+ // HasMatrix reports whether the strategy has a matrix. Without one,
50+ // Combos holds a single empty combination: the job runs once.
51+ HasMatrix bool
52+ // Combos are the jobs, in the order GitHub creates them.
53+ Combos []MatrixCombo
54+}
55+
56+// ExpandMatrix expands the matrix of an evaluated strategy (a dictionary with
57+// the optional keys "matrix", "fail-fast" and "max-parallel", or null) into
58+// the job combinations, like GitHub:
59+//
60+// - the combinations are the cross product of the vectors, the first key
61+// varying slowest;
62+// - exclude removes every combination that contains all the key-value
63+// pairs of one of its entries;
64+// - then each include entry is added to every remaining combination whose
65+// values for the vector keys it mentions are equal (an entry that
66+// mentions no vector key matches all), without overwriting the original
67+// values; an entry that matches no combination becomes a combination of
68+// its own;
69+// - more than 256 jobs is an error. A strategy without a matrix gives a
70+// single combination.
71+//
72+// TODO(verify with recording): the documented semantics of include and
73+// exclude are implemented as documented, but the following are assumed: the
74+// order of the cross product (first key slowest), the display name listing
75+// every scalar value including those added by include (see
76+// [MatrixCombo.DisplayName]), the comparison of values (same kind, strings
77+// compared exactly, mappings by containment), an include key matching a
78+// vector key case-insensitively, the error when every combination is
79+// excluded, and the wording of all errors.
80+func ExpandMatrix(strategy expr.Value) ([]MatrixCombo, error) {
81+ s, err := ParseStrategy(strategy)
82+ if err != nil {
83+ return nil, err
84+ }
85+ return s.Combos, nil
86+}
87+
88+// ParseStrategy reads an evaluated strategy value and expands its matrix.
89+func ParseStrategy(v expr.Value) (*Strategy, error) {
90+ s := &Strategy{FailFast: true}
91+ var matrix expr.Value
92+ switch d := v.(type) {
93+ case nil, expr.Null:
94+ case *expr.Dictionary:
95+ if d == nil {
96+ break
97+ }
98+ for _, p := range d.Pairs() {
99+ switch strings.ToLower(p.Key) {
100+ case "fail-fast":
101+ b, err := strategyBool(p.Value)
102+ if err != nil {
103+ return nil, fmt.Errorf("Unexpected value for 'fail-fast': %w", err)
104+ }
105+ s.FailFast = b
106+ case "max-parallel":
107+ n, err := strategyInt(p.Value)
108+ if err != nil {
109+ return nil, fmt.Errorf("Unexpected value for 'max-parallel': %w", err)
110+ }
111+ s.MaxParallel = n
112+ case "matrix":
113+ matrix = p.Value
114+ }
115+ }
116+ default:
117+ return nil, errors.New("The strategy must be a mapping")
118+ }
119+ combos, has, err := buildMatrix(matrix)
120+ if err != nil {
121+ return nil, err
122+ }
123+ s.Combos, s.HasMatrix = combos, has
124+ return s, nil
125+}
126+
127+// Context returns the "strategy" context of job number index (from 0).
128+func (s *Strategy) Context(index int) *expr.Dictionary {
129+ d := expr.NewDictionary()
130+ d.Add("fail-fast", expr.Boolean(s.FailFast))
131+ d.Add("job-index", expr.Number(index))
132+ d.Add("job-total", expr.Number(len(s.Combos)))
133+ max := s.MaxParallel
134+ if max == 0 {
135+ max = len(s.Combos)
136+ }
137+ d.Add("max-parallel", expr.Number(max))
138+ return d
139+}
140+
141+func strategyBool(v expr.Value) (bool, error) {
142+ switch x := v.(type) {
143+ case expr.Boolean:
144+ return bool(x), nil
145+ case expr.String:
146+ switch strings.ToLower(string(x)) {
147+ case "true":
148+ return true, nil
149+ case "false":
150+ return false, nil
151+ }
152+ }
153+ return false, fmt.Errorf("expected a boolean but got %s", kindName(v))
154+}
155+
156+func strategyInt(v expr.Value) (int, error) {
157+ var f float64
158+ switch x := v.(type) {
159+ case expr.Number:
160+ f = float64(x)
161+ case expr.String:
162+ n, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64)
163+ if err != nil {
164+ return 0, fmt.Errorf("expected a number but got '%s'", string(x))
165+ }
166+ f = n
167+ default:
168+ return 0, fmt.Errorf("expected a number but got %s", kindName(v))
169+ }
170+ if f != float64(int(f)) || f < 1 || f > 1<<31 {
171+ return 0, errors.New("expected a whole number greater than 0")
172+ }
173+ return int(f), nil
174+}
175+
176+func kindName(v expr.Value) string {
177+ if v == nil {
178+ return expr.KindNull.String()
179+ }
180+ return v.Kind().String()
181+}
182+
183+func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) {
184+ switch m.(type) {
185+ case nil, expr.Null:
186+ return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
187+ }
188+ md, ok := m.(*expr.Dictionary)
189+ if !ok {
190+ return nil, false, errors.New("The matrix must be a mapping")
191+ }
192+ if md == nil {
193+ return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
194+ }
195+ var keys []string
196+ vectors := map[string][]expr.Value{}
197+ var includes, excludes []*expr.Dictionary
198+ for _, p := range md.Pairs() {
199+ switch p.Key {
200+ case "include":
201+ list, err := matrixEntries("include", p.Value)
202+ if err != nil {
203+ return nil, false, err
204+ }
205+ includes = list
206+ case "exclude":
207+ list, err := matrixEntries("exclude", p.Value)
208+ if err != nil {
209+ return nil, false, err
210+ }
211+ excludes = list
212+ default:
213+ arr, ok := p.Value.(*expr.Array)
214+ if !ok || arr == nil {
215+ return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key)
216+ }
217+ if arr.Len() == 0 {
218+ return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key)
219+ }
220+ keys = append(keys, p.Key)
221+ vectors[p.Key] = arr.Items()
222+ }
223+ }
224+ return expand(keys, vectors, includes, excludes)
225+}
226+
227+func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) {
228+ arr, ok := v.(*expr.Array)
229+ if !ok || arr == nil {
230+ return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name)
231+ }
232+ var out []*expr.Dictionary
233+ for _, it := range arr.Items() {
234+ d, ok := it.(*expr.Dictionary)
235+ if !ok || d == nil {
236+ return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name)
237+ }
238+ out = append(out, d)
239+ }
240+ return out, nil
241+}
242+
243+// comboBuilder holds the values of one combination while includes are
244+// applied; added values may be overwritten by later includes, original ones
245+// never are.
246+type comboBuilder struct {
247+ keys []string
248+ vals map[string]expr.Value // by lower-cased key
249+}
250+
251+func (b *comboBuilder) set(key string, v expr.Value) {
252+ lk := strings.ToLower(key)
253+ if _, ok := b.vals[lk]; !ok {
254+ b.keys = append(b.keys, key)
255+ }
256+ b.vals[lk] = v
257+}
258+
259+func (b *comboBuilder) combo() MatrixCombo {
260+ d := expr.NewDictionary()
261+ var segs []string
262+ for _, k := range b.keys {
263+ v := b.vals[strings.ToLower(k)]
264+ d.Add(k, v)
265+ if s, ok := segment(v); ok {
266+ segs = append(segs, s)
267+ }
268+ }
269+ return MatrixCombo{Values: d, Segments: segs}
270+}
271+
272+func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) {
273+ var combos []MatrixCombo
274+ matched := make([]bool, len(includes))
275+ tooBig := fmt.Errorf("Matrix size limit of %d exceeded", MaxMatrixJobs)
276+ if len(keys) > 0 {
277+ product := 1
278+ for _, k := range keys {
279+ product *= len(vectors[k])
280+ if product > maxMatrixProduct {
281+ return nil, false, tooBig
282+ }
283+ }
284+ for i := 0; i < product; i++ {
285+ b := &comboBuilder{vals: map[string]expr.Value{}}
286+ div := product
287+ for _, k := range keys {
288+ div /= len(vectors[k])
289+ b.set(k, vectors[k][(i/div)%len(vectors[k])])
290+ }
291+ base := b.combo().Values
292+ if excluded(base, excludes) {
293+ continue
294+ }
295+ for n, inc := range includes {
296+ if !includeMatches(base, keys, inc) {
297+ continue
298+ }
299+ matched[n] = true
300+ for _, p := range inc.Pairs() {
301+ if _, isVector := vectors[p.Key]; !isVector && !hasKeyFold(keys, p.Key) {
302+ b.set(p.Key, p.Value)
303+ }
304+ }
305+ }
306+ combos = append(combos, b.combo())
307+ if len(combos) > MaxMatrixJobs {
308+ return nil, false, tooBig
309+ }
310+ }
311+ }
312+ for n, inc := range includes {
313+ if matched[n] {
314+ continue
315+ }
316+ b := &comboBuilder{vals: map[string]expr.Value{}}
317+ for _, p := range inc.Pairs() {
318+ b.set(p.Key, p.Value)
319+ }
320+ combos = append(combos, b.combo())
321+ if len(combos) > MaxMatrixJobs {
322+ return nil, false, tooBig
323+ }
324+ }
325+ if len(combos) == 0 {
326+ if len(keys) == 0 && len(includes) == 0 && len(excludes) == 0 {
327+ // An empty matrix runs the job once.
328+ return []MatrixCombo{{Values: expr.NewDictionary()}}, true, nil
329+ }
330+ // TODO(verify with recording): GitHub's behaviour when every
331+ // combination is excluded.
332+ return nil, false, errors.New("The matrix has no combinations: all of them are excluded")
333+ }
334+ return combos, true, nil
335+}
336+
337+func hasKeyFold(keys []string, key string) bool {
338+ for _, k := range keys {
339+ if strings.EqualFold(k, key) {
340+ return true
341+ }
342+ }
343+ return false
344+}
345+
346+// excluded reports whether any exclude entry is contained in vals.
347+func excluded(vals *expr.Dictionary, excludes []*expr.Dictionary) bool {
348+ for _, ex := range excludes {
349+ if ex.Len() == 0 {
350+ continue
351+ }
352+ all := true
353+ for _, p := range ex.Pairs() {
354+ have, ok := vals.Get(p.Key)
355+ if !ok || !valuesMatch(have, p.Value) {
356+ all = false
357+ break
358+ }
359+ }
360+ if all {
361+ return true
362+ }
363+ }
364+ return false
365+}
366+
367+// includeMatches reports whether an include entry applies to vals: every key
368+// of the entry that is a vector of the matrix has the same value. An entry
369+// with no such key applies to all combinations.
370+func includeMatches(vals *expr.Dictionary, vectorKeys []string, inc *expr.Dictionary) bool {
371+ for _, p := range inc.Pairs() {
372+ if !hasKeyFold(vectorKeys, p.Key) {
373+ continue
374+ }
375+ have, _ := vals.Get(p.Key)
376+ if !valuesMatch(have, p.Value) {
377+ return false
378+ }
379+ }
380+ return true
381+}
382+
383+// valuesMatch compares a matrix value with the value in an include or
384+// exclude entry: scalars of the same kind and value are equal, arrays must be
385+// equal element by element, and a mapping in the entry must be contained in
386+// the matrix value.
387+func valuesMatch(have, want expr.Value) bool {
388+ if kindName(have) != kindName(want) {
389+ return false
390+ }
391+ switch w := want.(type) {
392+ case expr.Null:
393+ return true
394+ case expr.Boolean:
395+ return have.(expr.Boolean) == w
396+ case expr.Number:
397+ return have.(expr.Number) == w
398+ case expr.String:
399+ return have.(expr.String) == w
400+ case *expr.Array:
401+ h := have.(*expr.Array)
402+ if h.Len() != w.Len() {
403+ return false
404+ }
405+ for i := 0; i < w.Len(); i++ {
406+ if !valuesMatch(h.At(i), w.At(i)) {
407+ return false
408+ }
409+ }
410+ return true
411+ case *expr.Dictionary:
412+ h := have.(*expr.Dictionary)
413+ for _, p := range w.Pairs() {
414+ hv, ok := h.Get(p.Key)
415+ if !ok || !valuesMatch(hv, p.Value) {
416+ return false
417+ }
418+ }
419+ return true
420+ }
421+ return false
422+}
423+
424+// segment is the text a matrix value adds to the name of a job; arrays and
425+// mappings add nothing.
426+func segment(v expr.Value) (string, bool) {
427+ switch v.(type) {
428+ case *expr.Array, *expr.Dictionary:
429+ return "", false
430+ }
431+ return expr.CoerceString(v), true
432+}
@@ -0,0 +1,432 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "strconv"
9+ "strings"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+// MaxMatrixJobs is the number of jobs a matrix may produce.
15+const MaxMatrixJobs = 256
16+
17+// maxMatrixProduct bounds the size of the cross product before exclusions,
18+// so that a matrix of many large vectors fails fast instead of looping.
19+// GitHub only limits the final number of jobs.
20+const maxMatrixProduct = 1 << 20
21+
22+// MatrixCombo is one job of a matrix.
23+type MatrixCombo struct {
24+ // Values is the "matrix" context of the job: the vector values in the
25+ // order of the keys, then the keys added by include entries in the order
26+ // they were first added.
27+ Values *expr.Dictionary
28+ // Segments are the scalar values of Values, in order, shown in the name
29+ // of the job: "build (ubuntu-latest, 14)". Arrays and mappings are left
30+ // out.
31+ Segments []string
32+}
33+
34+// DisplayName returns the name of the job: base alone when there are no
35+// segments, else base followed by the segments in parentheses.
36+func (c MatrixCombo) DisplayName(base string) string {
37+ if len(c.Segments) == 0 {
38+ return base
39+ }
40+ return base + " (" + strings.Join(c.Segments, ", ") + ")"
41+}
42+
43+// Strategy is an evaluated "strategy" key.
44+type Strategy struct {
45+ // FailFast is true unless the file says otherwise.
46+ FailFast bool
47+ // MaxParallel is the limit of concurrent jobs, 0 for no limit.
48+ MaxParallel int
49+ // HasMatrix reports whether the strategy has a matrix. Without one,
50+ // Combos holds a single empty combination: the job runs once.
51+ HasMatrix bool
52+ // Combos are the jobs, in the order GitHub creates them.
53+ Combos []MatrixCombo
54+}
55+
56+// ExpandMatrix expands the matrix of an evaluated strategy (a dictionary with
57+// the optional keys "matrix", "fail-fast" and "max-parallel", or null) into
58+// the job combinations, like GitHub:
59+//
60+// - the combinations are the cross product of the vectors, the first key
61+// varying slowest;
62+// - exclude removes every combination that contains all the key-value
63+// pairs of one of its entries;
64+// - then each include entry is added to every remaining combination whose
65+// values for the vector keys it mentions are equal (an entry that
66+// mentions no vector key matches all), without overwriting the original
67+// values; an entry that matches no combination becomes a combination of
68+// its own;
69+// - more than 256 jobs is an error. A strategy without a matrix gives a
70+// single combination.
71+//
72+// TODO(verify with recording): the documented semantics of include and
73+// exclude are implemented as documented, but the following are assumed: the
74+// order of the cross product (first key slowest), the display name listing
75+// every scalar value including those added by include (see
76+// [MatrixCombo.DisplayName]), the comparison of values (same kind, strings
77+// compared exactly, mappings by containment), an include key matching a
78+// vector key case-insensitively, the error when every combination is
79+// excluded, and the wording of all errors.
80+func ExpandMatrix(strategy expr.Value) ([]MatrixCombo, error) {
81+ s, err := ParseStrategy(strategy)
82+ if err != nil {
83+ return nil, err
84+ }
85+ return s.Combos, nil
86+}
87+
88+// ParseStrategy reads an evaluated strategy value and expands its matrix.
89+func ParseStrategy(v expr.Value) (*Strategy, error) {
90+ s := &Strategy{FailFast: true}
91+ var matrix expr.Value
92+ switch d := v.(type) {
93+ case nil, expr.Null:
94+ case *expr.Dictionary:
95+ if d == nil {
96+ break
97+ }
98+ for _, p := range d.Pairs() {
99+ switch strings.ToLower(p.Key) {
100+ case "fail-fast":
101+ b, err := strategyBool(p.Value)
102+ if err != nil {
103+ return nil, fmt.Errorf("Unexpected value for 'fail-fast': %w", err)
104+ }
105+ s.FailFast = b
106+ case "max-parallel":
107+ n, err := strategyInt(p.Value)
108+ if err != nil {
109+ return nil, fmt.Errorf("Unexpected value for 'max-parallel': %w", err)
110+ }
111+ s.MaxParallel = n
112+ case "matrix":
113+ matrix = p.Value
114+ }
115+ }
116+ default:
117+ return nil, errors.New("The strategy must be a mapping")
118+ }
119+ combos, has, err := buildMatrix(matrix)
120+ if err != nil {
121+ return nil, err
122+ }
123+ s.Combos, s.HasMatrix = combos, has
124+ return s, nil
125+}
126+
127+// Context returns the "strategy" context of job number index (from 0).
128+func (s *Strategy) Context(index int) *expr.Dictionary {
129+ d := expr.NewDictionary()
130+ d.Add("fail-fast", expr.Boolean(s.FailFast))
131+ d.Add("job-index", expr.Number(index))
132+ d.Add("job-total", expr.Number(len(s.Combos)))
133+ max := s.MaxParallel
134+ if max == 0 {
135+ max = len(s.Combos)
136+ }
137+ d.Add("max-parallel", expr.Number(max))
138+ return d
139+}
140+
141+func strategyBool(v expr.Value) (bool, error) {
142+ switch x := v.(type) {
143+ case expr.Boolean:
144+ return bool(x), nil
145+ case expr.String:
146+ switch strings.ToLower(string(x)) {
147+ case "true":
148+ return true, nil
149+ case "false":
150+ return false, nil
151+ }
152+ }
153+ return false, fmt.Errorf("expected a boolean but got %s", kindName(v))
154+}
155+
156+func strategyInt(v expr.Value) (int, error) {
157+ var f float64
158+ switch x := v.(type) {
159+ case expr.Number:
160+ f = float64(x)
161+ case expr.String:
162+ n, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64)
163+ if err != nil {
164+ return 0, fmt.Errorf("expected a number but got '%s'", string(x))
165+ }
166+ f = n
167+ default:
168+ return 0, fmt.Errorf("expected a number but got %s", kindName(v))
169+ }
170+ if f != float64(int(f)) || f < 1 || f > 1<<31 {
171+ return 0, errors.New("expected a whole number greater than 0")
172+ }
173+ return int(f), nil
174+}
175+
176+func kindName(v expr.Value) string {
177+ if v == nil {
178+ return expr.KindNull.String()
179+ }
180+ return v.Kind().String()
181+}
182+
183+func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) {
184+ switch m.(type) {
185+ case nil, expr.Null:
186+ return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
187+ }
188+ md, ok := m.(*expr.Dictionary)
189+ if !ok {
190+ return nil, false, errors.New("The matrix must be a mapping")
191+ }
192+ if md == nil {
193+ return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
194+ }
195+ var keys []string
196+ vectors := map[string][]expr.Value{}
197+ var includes, excludes []*expr.Dictionary
198+ for _, p := range md.Pairs() {
199+ switch p.Key {
200+ case "include":
201+ list, err := matrixEntries("include", p.Value)
202+ if err != nil {
203+ return nil, false, err
204+ }
205+ includes = list
206+ case "exclude":
207+ list, err := matrixEntries("exclude", p.Value)
208+ if err != nil {
209+ return nil, false, err
210+ }
211+ excludes = list
212+ default:
213+ arr, ok := p.Value.(*expr.Array)
214+ if !ok || arr == nil {
215+ return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key)
216+ }
217+ if arr.Len() == 0 {
218+ return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key)
219+ }
220+ keys = append(keys, p.Key)
221+ vectors[p.Key] = arr.Items()
222+ }
223+ }
224+ return expand(keys, vectors, includes, excludes)
225+}
226+
227+func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) {
228+ arr, ok := v.(*expr.Array)
229+ if !ok || arr == nil {
230+ return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name)
231+ }
232+ var out []*expr.Dictionary
233+ for _, it := range arr.Items() {
234+ d, ok := it.(*expr.Dictionary)
235+ if !ok || d == nil {
236+ return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name)
237+ }
238+ out = append(out, d)
239+ }
240+ return out, nil
241+}
242+
243+// comboBuilder holds the values of one combination while includes are
244+// applied; added values may be overwritten by later includes, original ones
245+// never are.
246+type comboBuilder struct {
247+ keys []string
248+ vals map[string]expr.Value // by lower-cased key
249+}
250+
251+func (b *comboBuilder) set(key string, v expr.Value) {
252+ lk := strings.ToLower(key)
253+ if _, ok := b.vals[lk]; !ok {
254+ b.keys = append(b.keys, key)
255+ }
256+ b.vals[lk] = v
257+}
258+
259+func (b *comboBuilder) combo() MatrixCombo {
260+ d := expr.NewDictionary()
261+ var segs []string
262+ for _, k := range b.keys {
263+ v := b.vals[strings.ToLower(k)]
264+ d.Add(k, v)
265+ if s, ok := segment(v); ok {
266+ segs = append(segs, s)
267+ }
268+ }
269+ return MatrixCombo{Values: d, Segments: segs}
270+}
271+
272+func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) {
273+ var combos []MatrixCombo
274+ matched := make([]bool, len(includes))
275+ tooBig := fmt.Errorf("Matrix size limit of %d exceeded", MaxMatrixJobs)
276+ if len(keys) > 0 {
277+ product := 1
278+ for _, k := range keys {
279+ product *= len(vectors[k])
280+ if product > maxMatrixProduct {
281+ return nil, false, tooBig
282+ }
283+ }
284+ for i := 0; i < product; i++ {
285+ b := &comboBuilder{vals: map[string]expr.Value{}}
286+ div := product
287+ for _, k := range keys {
288+ div /= len(vectors[k])
289+ b.set(k, vectors[k][(i/div)%len(vectors[k])])
290+ }
291+ base := b.combo().Values
292+ if excluded(base, excludes) {
293+ continue
294+ }
295+ for n, inc := range includes {
296+ if !includeMatches(base, keys, inc) {
297+ continue
298+ }
299+ matched[n] = true
300+ for _, p := range inc.Pairs() {
301+ if _, isVector := vectors[p.Key]; !isVector && !hasKeyFold(keys, p.Key) {
302+ b.set(p.Key, p.Value)
303+ }
304+ }
305+ }
306+ combos = append(combos, b.combo())
307+ if len(combos) > MaxMatrixJobs {
308+ return nil, false, tooBig
309+ }
310+ }
311+ }
312+ for n, inc := range includes {
313+ if matched[n] {
314+ continue
315+ }
316+ b := &comboBuilder{vals: map[string]expr.Value{}}
317+ for _, p := range inc.Pairs() {
318+ b.set(p.Key, p.Value)
319+ }
320+ combos = append(combos, b.combo())
321+ if len(combos) > MaxMatrixJobs {
322+ return nil, false, tooBig
323+ }
324+ }
325+ if len(combos) == 0 {
326+ if len(keys) == 0 && len(includes) == 0 && len(excludes) == 0 {
327+ // An empty matrix runs the job once.
328+ return []MatrixCombo{{Values: expr.NewDictionary()}}, true, nil
329+ }
330+ // TODO(verify with recording): GitHub's behaviour when every
331+ // combination is excluded.
332+ return nil, false, errors.New("The matrix has no combinations: all of them are excluded")
333+ }
334+ return combos, true, nil
335+}
336+
337+func hasKeyFold(keys []string, key string) bool {
338+ for _, k := range keys {
339+ if strings.EqualFold(k, key) {
340+ return true
341+ }
342+ }
343+ return false
344+}
345+
346+// excluded reports whether any exclude entry is contained in vals.
347+func excluded(vals *expr.Dictionary, excludes []*expr.Dictionary) bool {
348+ for _, ex := range excludes {
349+ if ex.Len() == 0 {
350+ continue
351+ }
352+ all := true
353+ for _, p := range ex.Pairs() {
354+ have, ok := vals.Get(p.Key)
355+ if !ok || !valuesMatch(have, p.Value) {
356+ all = false
357+ break
358+ }
359+ }
360+ if all {
361+ return true
362+ }
363+ }
364+ return false
365+}
366+
367+// includeMatches reports whether an include entry applies to vals: every key
368+// of the entry that is a vector of the matrix has the same value. An entry
369+// with no such key applies to all combinations.
370+func includeMatches(vals *expr.Dictionary, vectorKeys []string, inc *expr.Dictionary) bool {
371+ for _, p := range inc.Pairs() {
372+ if !hasKeyFold(vectorKeys, p.Key) {
373+ continue
374+ }
375+ have, _ := vals.Get(p.Key)
376+ if !valuesMatch(have, p.Value) {
377+ return false
378+ }
379+ }
380+ return true
381+}
382+
383+// valuesMatch compares a matrix value with the value in an include or
384+// exclude entry: scalars of the same kind and value are equal, arrays must be
385+// equal element by element, and a mapping in the entry must be contained in
386+// the matrix value.
387+func valuesMatch(have, want expr.Value) bool {
388+ if kindName(have) != kindName(want) {
389+ return false
390+ }
391+ switch w := want.(type) {
392+ case expr.Null:
393+ return true
394+ case expr.Boolean:
395+ return have.(expr.Boolean) == w
396+ case expr.Number:
397+ return have.(expr.Number) == w
398+ case expr.String:
399+ return have.(expr.String) == w
400+ case *expr.Array:
401+ h := have.(*expr.Array)
402+ if h.Len() != w.Len() {
403+ return false
404+ }
405+ for i := 0; i < w.Len(); i++ {
406+ if !valuesMatch(h.At(i), w.At(i)) {
407+ return false
408+ }
409+ }
410+ return true
411+ case *expr.Dictionary:
412+ h := have.(*expr.Dictionary)
413+ for _, p := range w.Pairs() {
414+ hv, ok := h.Get(p.Key)
415+ if !ok || !valuesMatch(hv, p.Value) {
416+ return false
417+ }
418+ }
419+ return true
420+ }
421+ return false
422+}
423+
424+// segment is the text a matrix value adds to the name of a job; arrays and
425+// mappings add nothing.
426+func segment(v expr.Value) (string, bool) {
427+ switch v.(type) {
428+ case *expr.Array, *expr.Dictionary:
429+ return "", false
430+ }
431+ return expr.CoerceString(v), true
432+}
A workflow/matrix_test.go +241 -0
@@ -0,0 +1,241 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "fmt"
7+ "reflect"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+func jsonValue(t testing.TB, s string) expr.Value {
15+ t.Helper()
16+ v, err := expr.FromJSON([]byte(s))
17+ if err != nil {
18+ t.Fatalf("FromJSON(%s): %v", s, err)
19+ }
20+ return v
21+}
22+
23+func compact(s string) string {
24+ return strings.NewReplacer("\n", "", " ", "", `": `, `":`).Replace(s)
25+}
26+
27+// summarize renders combos as "k=v,k=v" lines with the display segments.
28+func summarize(combos []MatrixCombo) []string {
29+ var out []string
30+ for _, c := range combos {
31+ var kv []string
32+ for _, p := range c.Values.Pairs() {
33+ kv = append(kv, p.Key+"="+compact(expr.ToJSON(p.Value)))
34+ }
35+ out = append(out, strings.Join(kv, ",")+" | "+c.DisplayName("job"))
36+ }
37+ return out
38+}
39+
40+func TestExpandMatrix(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ strategy string
44+ want []string
45+ }{
46+ {"no strategy", `null`, []string{" | job"}},
47+ {"strategy without matrix", `{"fail-fast": false}`, []string{" | job"}},
48+ {"empty matrix", `{"matrix": {}}`, []string{" | job"}},
49+ {
50+ "one vector",
51+ `{"matrix": {"os": ["ubuntu", "macos"]}}`,
52+ []string{`os="ubuntu" | job (ubuntu)`, `os="macos" | job (macos)`},
53+ },
54+ {
55+ "cross product, first key slowest",
56+ `{"matrix": {"os": ["a", "b"], "node": [14, 16, 18]}}`,
57+ []string{
58+ `os="a",node=14 | job (a, 14)`, `os="a",node=16 | job (a, 16)`, `os="a",node=18 | job (a, 18)`,
59+ `os="b",node=14 | job (b, 14)`, `os="b",node=16 | job (b, 16)`, `os="b",node=18 | job (b, 18)`,
60+ },
61+ },
62+ {
63+ // GitHub docs, "Expanding or adding matrix configurations".
64+ "docs include example",
65+ `{"matrix": {"fruit": ["apple", "pear"], "animal": ["cat", "dog"], "include": [
66+ {"color": "green"},
67+ {"color": "pink", "animal": "cat"},
68+ {"fruit": "apple", "shape": "circle"},
69+ {"fruit": "banana"},
70+ {"fruit": "banana", "animal": "cat"}
71+ ]}}`,
72+ []string{
73+ `fruit="apple",animal="cat",color="pink",shape="circle" | job (apple, cat, pink, circle)`,
74+ `fruit="apple",animal="dog",color="green",shape="circle" | job (apple, dog, green, circle)`,
75+ `fruit="pear",animal="cat",color="pink" | job (pear, cat, pink)`,
76+ `fruit="pear",animal="dog",color="green" | job (pear, dog, green)`,
77+ `fruit="banana" | job (banana)`,
78+ `fruit="banana",animal="cat" | job (banana, cat)`,
79+ },
80+ },
81+ {
82+ // GitHub docs, "Excluding matrix configurations".
83+ "docs exclude example",
84+ `{"matrix": {"os": ["macos-latest", "windows-latest"], "version": [12, 14, 16], "environment": ["staging", "production"],
85+ "exclude": [{"os": "macos-latest", "version": 12, "environment": "production"}, {"os": "windows-latest", "version": 16}]}}`,
86+ []string{
87+ `os="macos-latest",version=12,environment="staging" | job (macos-latest, 12, staging)`,
88+ `os="macos-latest",version=14,environment="staging" | job (macos-latest, 14, staging)`,
89+ `os="macos-latest",version=14,environment="production" | job (macos-latest, 14, production)`,
90+ `os="macos-latest",version=16,environment="staging" | job (macos-latest, 16, staging)`,
91+ `os="macos-latest",version=16,environment="production" | job (macos-latest, 16, production)`,
92+ `os="windows-latest",version=12,environment="staging" | job (windows-latest, 12, staging)`,
93+ `os="windows-latest",version=12,environment="production" | job (windows-latest, 12, production)`,
94+ `os="windows-latest",version=14,environment="staging" | job (windows-latest, 14, staging)`,
95+ `os="windows-latest",version=14,environment="production" | job (windows-latest, 14, production)`,
96+ },
97+ },
98+ {
99+ // GitHub docs, "Using a matrix with only include": the include
100+ // entries do not overwrite a vector value.
101+ "include does not overwrite original values",
102+ `{"matrix": {"os": ["a", "b"], "include": [{"os": "c"}, {"os": "a", "node": 1}]}}`,
103+ []string{
104+ `os="a",node=1 | job (a, 1)`,
105+ `os="b" | job (b)`,
106+ `os="c" | job (c)`,
107+ },
108+ },
109+ {
110+ "include only",
111+ `{"matrix": {"include": [{"os": "a", "n": 1}, {"os": "b", "n": 2}]}}`,
112+ []string{`os="a",n=1 | job (a, 1)`, `os="b",n=2 | job (b, 2)`},
113+ },
114+ {
115+ "added value overwritten by a later include",
116+ `{"matrix": {"os": ["a"], "include": [{"color": "red"}, {"color": "blue", "os": "a"}]}}`,
117+ []string{`os="a",color="blue" | job (a, blue)`},
118+ },
119+ {
120+ "exclude then include re-adds",
121+ `{"matrix": {"os": ["a", "b"], "exclude": [{"os": "a"}], "include": [{"os": "a", "x": 1}]}}`,
122+ []string{`os="b" | job (b)`, `os="a",x=1 | job (a, 1)`},
123+ },
124+ {
125+ "exclude partial match",
126+ `{"matrix": {"a": [1, 2], "b": [1, 2], "exclude": [{"a": 1}]}}`,
127+ []string{`a=2,b=1 | job (2, 1)`, `a=2,b=2 | job (2, 2)`},
128+ },
129+ {
130+ "exclude no match",
131+ `{"matrix": {"a": [1], "exclude": [{"a": 2}, {"zzz": 1}]}}`,
132+ []string{`a=1 | job (1)`},
133+ },
134+ {
135+ "booleans, null and numbers in names",
136+ `{"matrix": {"a": [true, null, 1.5]}}`,
137+ []string{`a=true | job (true)`, `a=null | job ()`, `a=1.5 | job (1.5)`},
138+ },
139+ {
140+ "objects and arrays are not in names",
141+ `{"matrix": {"v": [{"k": 1}, [1]], "w": ["x"]}}`,
142+ []string{`v={"k":1},w="x" | job (x)`, `v=[1],w="x" | job (x)`},
143+ },
144+ {
145+ "exclude by object containment",
146+ `{"matrix": {"v": [{"k": 1, "m": 2}, {"k": 2}], "exclude": [{"v": {"k": 1}}]}}`,
147+ []string{`v={"k":2} | job`},
148+ },
149+ {
150+ "keys are case-insensitive for include matching",
151+ `{"matrix": {"OS": ["a"], "include": [{"os": "a", "x": 1}]}}`,
152+ []string{`OS="a",x=1 | job (a, 1)`},
153+ },
154+ }
155+ for _, tc := range tests {
156+ t.Run(tc.name, func(t *testing.T) {
157+ combos, err := ExpandMatrix(jsonValue(t, tc.strategy))
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ if got := summarize(combos); !reflect.DeepEqual(got, tc.want) {
162+ t.Errorf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(tc.want, "\n "))
163+ }
164+ })
165+ }
166+}
167+
168+func TestExpandMatrixErrors(t *testing.T) {
169+ tests := []struct{ name, strategy, want string }{
170+ {"strategy not a mapping", `[1]`, "The strategy must be a mapping"},
171+ {"matrix not a mapping", `{"matrix": [1]}`, "The matrix must be a mapping"},
172+ {"vector empty", `{"matrix": {"a": []}}`, "Matrix vector 'a' does not contain any values"},
173+ {"vector scalar", `{"matrix": {"a": 1}}`, "Matrix vector 'a' does not contain any values"},
174+ {"include not a list", `{"matrix": {"include": 1}}`, "Matrix include must be a sequence of mappings"},
175+ {"include item", `{"matrix": {"include": [1]}}`, "Matrix include must be a sequence of mappings"},
176+ {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "Matrix exclude must be a sequence of mappings"},
177+ {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"},
178+ {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"},
179+ {"max-parallel", `{"max-parallel": 0}`, "max-parallel"},
180+ {"max-parallel fraction", `{"max-parallel": 1.5}`, "max-parallel"},
181+ }
182+ for _, tc := range tests {
183+ t.Run(tc.name, func(t *testing.T) {
184+ _, err := ExpandMatrix(jsonValue(t, tc.strategy))
185+ if err == nil || !strings.Contains(err.Error(), tc.want) {
186+ t.Errorf("err = %v, want it to contain %q", err, tc.want)
187+ }
188+ })
189+ }
190+}
191+
192+func TestMatrixLimit(t *testing.T) {
193+ vec := func(n int) string {
194+ var items []string
195+ for i := 0; i < n; i++ {
196+ items = append(items, fmt.Sprint(i))
197+ }
198+ return "[" + strings.Join(items, ",") + "]"
199+ }
200+ if c, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 {
201+ t.Errorf("256 jobs: %d, %v", len(c), err)
202+ }
203+ _, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`))
204+ if err == nil || !strings.Contains(err.Error(), "Matrix size limit of 256 exceeded") {
205+ t.Errorf("257 jobs: %v", err)
206+ }
207+ _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`))
208+ if err == nil {
209+ t.Error("272 jobs: no error")
210+ }
211+ // Excluding brings it back under the limit.
212+ if c, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`, "exclude": [{"a": 16}]}}`)); err != nil || len(c) != 256 {
213+ t.Errorf("272 minus 16: %d, %v", len(c), err)
214+ }
215+ // A huge product fails fast.
216+ _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`))
217+ if err == nil {
218+ t.Error("huge product: no error")
219+ }
220+}
221+
222+func TestStrategyOptions(t *testing.T) {
223+ s, err := ParseStrategy(jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`))
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ if s.FailFast || s.MaxParallel != 2 || !s.HasMatrix || len(s.Combos) != 3 {
228+ t.Errorf("%+v", s)
229+ }
230+ d := s.Context(1)
231+ if got := expr.ToJSON(d); got != "{\n \"fail-fast\": false,\n \"job-index\": 1,\n \"job-total\": 3,\n \"max-parallel\": 2\n}" {
232+ t.Errorf("context = %s", got)
233+ }
234+ s, err = ParseStrategy(nil)
235+ if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 {
236+ t.Errorf("default: %+v, %v", s, err)
237+ }
238+ if got := expr.ToJSON(s.Context(0)); !strings.Contains(got, `"max-parallel": 1`) {
239+ t.Errorf("default context = %s", got)
240+ }
241+}
@@ -0,0 +1,241 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "fmt"
7+ "reflect"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+func jsonValue(t testing.TB, s string) expr.Value {
15+ t.Helper()
16+ v, err := expr.FromJSON([]byte(s))
17+ if err != nil {
18+ t.Fatalf("FromJSON(%s): %v", s, err)
19+ }
20+ return v
21+}
22+
23+func compact(s string) string {
24+ return strings.NewReplacer("\n", "", " ", "", `": `, `":`).Replace(s)
25+}
26+
27+// summarize renders combos as "k=v,k=v" lines with the display segments.
28+func summarize(combos []MatrixCombo) []string {
29+ var out []string
30+ for _, c := range combos {
31+ var kv []string
32+ for _, p := range c.Values.Pairs() {
33+ kv = append(kv, p.Key+"="+compact(expr.ToJSON(p.Value)))
34+ }
35+ out = append(out, strings.Join(kv, ",")+" | "+c.DisplayName("job"))
36+ }
37+ return out
38+}
39+
40+func TestExpandMatrix(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ strategy string
44+ want []string
45+ }{
46+ {"no strategy", `null`, []string{" | job"}},
47+ {"strategy without matrix", `{"fail-fast": false}`, []string{" | job"}},
48+ {"empty matrix", `{"matrix": {}}`, []string{" | job"}},
49+ {
50+ "one vector",
51+ `{"matrix": {"os": ["ubuntu", "macos"]}}`,
52+ []string{`os="ubuntu" | job (ubuntu)`, `os="macos" | job (macos)`},
53+ },
54+ {
55+ "cross product, first key slowest",
56+ `{"matrix": {"os": ["a", "b"], "node": [14, 16, 18]}}`,
57+ []string{
58+ `os="a",node=14 | job (a, 14)`, `os="a",node=16 | job (a, 16)`, `os="a",node=18 | job (a, 18)`,
59+ `os="b",node=14 | job (b, 14)`, `os="b",node=16 | job (b, 16)`, `os="b",node=18 | job (b, 18)`,
60+ },
61+ },
62+ {
63+ // GitHub docs, "Expanding or adding matrix configurations".
64+ "docs include example",
65+ `{"matrix": {"fruit": ["apple", "pear"], "animal": ["cat", "dog"], "include": [
66+ {"color": "green"},
67+ {"color": "pink", "animal": "cat"},
68+ {"fruit": "apple", "shape": "circle"},
69+ {"fruit": "banana"},
70+ {"fruit": "banana", "animal": "cat"}
71+ ]}}`,
72+ []string{
73+ `fruit="apple",animal="cat",color="pink",shape="circle" | job (apple, cat, pink, circle)`,
74+ `fruit="apple",animal="dog",color="green",shape="circle" | job (apple, dog, green, circle)`,
75+ `fruit="pear",animal="cat",color="pink" | job (pear, cat, pink)`,
76+ `fruit="pear",animal="dog",color="green" | job (pear, dog, green)`,
77+ `fruit="banana" | job (banana)`,
78+ `fruit="banana",animal="cat" | job (banana, cat)`,
79+ },
80+ },
81+ {
82+ // GitHub docs, "Excluding matrix configurations".
83+ "docs exclude example",
84+ `{"matrix": {"os": ["macos-latest", "windows-latest"], "version": [12, 14, 16], "environment": ["staging", "production"],
85+ "exclude": [{"os": "macos-latest", "version": 12, "environment": "production"}, {"os": "windows-latest", "version": 16}]}}`,
86+ []string{
87+ `os="macos-latest",version=12,environment="staging" | job (macos-latest, 12, staging)`,
88+ `os="macos-latest",version=14,environment="staging" | job (macos-latest, 14, staging)`,
89+ `os="macos-latest",version=14,environment="production" | job (macos-latest, 14, production)`,
90+ `os="macos-latest",version=16,environment="staging" | job (macos-latest, 16, staging)`,
91+ `os="macos-latest",version=16,environment="production" | job (macos-latest, 16, production)`,
92+ `os="windows-latest",version=12,environment="staging" | job (windows-latest, 12, staging)`,
93+ `os="windows-latest",version=12,environment="production" | job (windows-latest, 12, production)`,
94+ `os="windows-latest",version=14,environment="staging" | job (windows-latest, 14, staging)`,
95+ `os="windows-latest",version=14,environment="production" | job (windows-latest, 14, production)`,
96+ },
97+ },
98+ {
99+ // GitHub docs, "Using a matrix with only include": the include
100+ // entries do not overwrite a vector value.
101+ "include does not overwrite original values",
102+ `{"matrix": {"os": ["a", "b"], "include": [{"os": "c"}, {"os": "a", "node": 1}]}}`,
103+ []string{
104+ `os="a",node=1 | job (a, 1)`,
105+ `os="b" | job (b)`,
106+ `os="c" | job (c)`,
107+ },
108+ },
109+ {
110+ "include only",
111+ `{"matrix": {"include": [{"os": "a", "n": 1}, {"os": "b", "n": 2}]}}`,
112+ []string{`os="a",n=1 | job (a, 1)`, `os="b",n=2 | job (b, 2)`},
113+ },
114+ {
115+ "added value overwritten by a later include",
116+ `{"matrix": {"os": ["a"], "include": [{"color": "red"}, {"color": "blue", "os": "a"}]}}`,
117+ []string{`os="a",color="blue" | job (a, blue)`},
118+ },
119+ {
120+ "exclude then include re-adds",
121+ `{"matrix": {"os": ["a", "b"], "exclude": [{"os": "a"}], "include": [{"os": "a", "x": 1}]}}`,
122+ []string{`os="b" | job (b)`, `os="a",x=1 | job (a, 1)`},
123+ },
124+ {
125+ "exclude partial match",
126+ `{"matrix": {"a": [1, 2], "b": [1, 2], "exclude": [{"a": 1}]}}`,
127+ []string{`a=2,b=1 | job (2, 1)`, `a=2,b=2 | job (2, 2)`},
128+ },
129+ {
130+ "exclude no match",
131+ `{"matrix": {"a": [1], "exclude": [{"a": 2}, {"zzz": 1}]}}`,
132+ []string{`a=1 | job (1)`},
133+ },
134+ {
135+ "booleans, null and numbers in names",
136+ `{"matrix": {"a": [true, null, 1.5]}}`,
137+ []string{`a=true | job (true)`, `a=null | job ()`, `a=1.5 | job (1.5)`},
138+ },
139+ {
140+ "objects and arrays are not in names",
141+ `{"matrix": {"v": [{"k": 1}, [1]], "w": ["x"]}}`,
142+ []string{`v={"k":1},w="x" | job (x)`, `v=[1],w="x" | job (x)`},
143+ },
144+ {
145+ "exclude by object containment",
146+ `{"matrix": {"v": [{"k": 1, "m": 2}, {"k": 2}], "exclude": [{"v": {"k": 1}}]}}`,
147+ []string{`v={"k":2} | job`},
148+ },
149+ {
150+ "keys are case-insensitive for include matching",
151+ `{"matrix": {"OS": ["a"], "include": [{"os": "a", "x": 1}]}}`,
152+ []string{`OS="a",x=1 | job (a, 1)`},
153+ },
154+ }
155+ for _, tc := range tests {
156+ t.Run(tc.name, func(t *testing.T) {
157+ combos, err := ExpandMatrix(jsonValue(t, tc.strategy))
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ if got := summarize(combos); !reflect.DeepEqual(got, tc.want) {
162+ t.Errorf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(tc.want, "\n "))
163+ }
164+ })
165+ }
166+}
167+
168+func TestExpandMatrixErrors(t *testing.T) {
169+ tests := []struct{ name, strategy, want string }{
170+ {"strategy not a mapping", `[1]`, "The strategy must be a mapping"},
171+ {"matrix not a mapping", `{"matrix": [1]}`, "The matrix must be a mapping"},
172+ {"vector empty", `{"matrix": {"a": []}}`, "Matrix vector 'a' does not contain any values"},
173+ {"vector scalar", `{"matrix": {"a": 1}}`, "Matrix vector 'a' does not contain any values"},
174+ {"include not a list", `{"matrix": {"include": 1}}`, "Matrix include must be a sequence of mappings"},
175+ {"include item", `{"matrix": {"include": [1]}}`, "Matrix include must be a sequence of mappings"},
176+ {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "Matrix exclude must be a sequence of mappings"},
177+ {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"},
178+ {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"},
179+ {"max-parallel", `{"max-parallel": 0}`, "max-parallel"},
180+ {"max-parallel fraction", `{"max-parallel": 1.5}`, "max-parallel"},
181+ }
182+ for _, tc := range tests {
183+ t.Run(tc.name, func(t *testing.T) {
184+ _, err := ExpandMatrix(jsonValue(t, tc.strategy))
185+ if err == nil || !strings.Contains(err.Error(), tc.want) {
186+ t.Errorf("err = %v, want it to contain %q", err, tc.want)
187+ }
188+ })
189+ }
190+}
191+
192+func TestMatrixLimit(t *testing.T) {
193+ vec := func(n int) string {
194+ var items []string
195+ for i := 0; i < n; i++ {
196+ items = append(items, fmt.Sprint(i))
197+ }
198+ return "[" + strings.Join(items, ",") + "]"
199+ }
200+ if c, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 {
201+ t.Errorf("256 jobs: %d, %v", len(c), err)
202+ }
203+ _, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`))
204+ if err == nil || !strings.Contains(err.Error(), "Matrix size limit of 256 exceeded") {
205+ t.Errorf("257 jobs: %v", err)
206+ }
207+ _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`))
208+ if err == nil {
209+ t.Error("272 jobs: no error")
210+ }
211+ // Excluding brings it back under the limit.
212+ if c, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`, "exclude": [{"a": 16}]}}`)); err != nil || len(c) != 256 {
213+ t.Errorf("272 minus 16: %d, %v", len(c), err)
214+ }
215+ // A huge product fails fast.
216+ _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`))
217+ if err == nil {
218+ t.Error("huge product: no error")
219+ }
220+}
221+
222+func TestStrategyOptions(t *testing.T) {
223+ s, err := ParseStrategy(jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`))
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ if s.FailFast || s.MaxParallel != 2 || !s.HasMatrix || len(s.Combos) != 3 {
228+ t.Errorf("%+v", s)
229+ }
230+ d := s.Context(1)
231+ if got := expr.ToJSON(d); got != "{\n \"fail-fast\": false,\n \"job-index\": 1,\n \"job-total\": 3,\n \"max-parallel\": 2\n}" {
232+ t.Errorf("context = %s", got)
233+ }
234+ s, err = ParseStrategy(nil)
235+ if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 {
236+ t.Errorf("default: %+v, %v", s, err)
237+ }
238+ if got := expr.ToJSON(s.Context(0)); !strings.Contains(got, `"max-parallel": 1`) {
239+ t.Errorf("default context = %s", got)
240+ }
241+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close