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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||