feat(workflow): bound matrix expansion, split strategy.go, add EvaluateRunsOn, EvaluateIf and JobLevels
- commit
- fa5b4911a768c0beb89f15f06453a519174f81da
- author
- brice · · 2026-10-12 16:14 UTC
- committer
- brice · · 2026-10-12 16:17 UTC
- parent
- d7622c6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +930 -318
M workflow/convert_misc.go +2 -15
| @@ -72,21 +72,8 @@ func (c *converter) convertRunsOn(t template.Token) { | |||
| 72 | 72 | continue | |
| 73 | 73 | } | |
| 74 | 74 | group := asString(p.Value, "job runs-on group name").Value | |
| 75 | - | names := strings.Split(group, "/") | |
| 76 | - | switch len(names) { | |
| 77 | - | case 1: | |
| 78 | - | case 2: | |
| 79 | - | switch names[0] { | |
| 80 | - | case "org", "organization", "ent", "enterprise": | |
| 81 | - | default: | |
| 82 | - | c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |
| 83 | - | continue | |
| 84 | - | } | |
| 85 | - | if names[1] == "" { | |
| 86 | - | c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s'.", group) | |
| 87 | - | } | |
| 88 | - | default: | |
| 89 | - | c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |
| 75 | + | if msg := checkGroup(group); msg != "" { | |
| 76 | + | c.ctx.ErrorAt(p.Value, msg) | |
| 90 | 77 | } | |
| 91 | 78 | case "labels": | |
| 92 | 79 | runsOnLabels(p.Value) | |
| @@ -72,21 +72,8 @@ func (c *converter) convertRunsOn(t template.Token) { | |||||
| 72 | continue | 72 | continue | ||
| 73 | } | 73 | } | ||
| 74 | group := asString(p.Value, "job runs-on group name").Value | 74 | group := asString(p.Value, "job runs-on group name").Value | ||
| 75 | - | names := strings.Split(group, "/") | 75 | + | if msg := checkGroup(group); msg != "" { |
| 76 | - | switch len(names) { | 76 | + | c.ctx.ErrorAt(p.Value, msg) |
| 77 | - | case 1: | |||
| 78 | - | case 2: | |||
| 79 | - | switch names[0] { | |||
| 80 | - | case "org", "organization", "ent", "enterprise": | |||
| 81 | - | default: | |||
| 82 | - | c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |||
| 83 | - | continue | |||
| 84 | - | } | |||
| 85 | - | if names[1] == "" { | |||
| 86 | - | c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s'.", group) | |||
| 87 | - | } | |||
| 88 | - | default: | |||
| 89 | - | c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |||
| 90 | } | 77 | } | ||
| 91 | case "labels": | 78 | case "labels": | ||
| 92 | runsOnLabels(p.Value) | 79 | runsOnLabels(p.Value) | ||
D workflow/eval.go +0 -77
| @@ -1,77 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: MIT | |
| 2 | - | ||
| 3 | - | package workflow | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "strings" | |
| 8 | - | ||
| 9 | - | "bnema.dev/ermite-actions/expr" | |
| 10 | - | "bnema.dev/ermite-actions/template" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // EvaluateStrategy evaluates the job's strategy token against env and | |
| 14 | - | // expands its matrix. env must provide the contexts the schema allows in | |
| 15 | - | // "strategy": github, inputs, vars and needs. A job without a strategy gives | |
| 16 | - | // a Strategy with one empty combination. | |
| 17 | - | func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) { | |
| 18 | - | if j.Strategy == nil { | |
| 19 | - | return ParseStrategy(nil) | |
| 20 | - | } | |
| 21 | - | v, err := template.Evaluate(ctx, j.Strategy, env) | |
| 22 | - | if err != nil { | |
| 23 | - | return nil, err | |
| 24 | - | } | |
| 25 | - | return ParseStrategy(v) | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // MatrixEnv returns env with the "matrix" and "strategy" contexts of one | |
| 29 | - | // combination added, for evaluating the parts of a job that may use them: | |
| 30 | - | // runs-on, name, env, container, services, steps... | |
| 31 | - | func (s *Strategy) MatrixEnv(env template.Env, index int) template.Env { | |
| 32 | - | out := env | |
| 33 | - | out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2) | |
| 34 | - | for k, v := range env.Contexts { | |
| 35 | - | out.Contexts[k] = v | |
| 36 | - | } | |
| 37 | - | out.Contexts["matrix"] = s.Combos[index].Values | |
| 38 | - | out.Contexts["strategy"] = s.Context(index) | |
| 39 | - | return out | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // DisplayName evaluates the job's name for combination index of its | |
| 43 | - | // strategy: the name token (or the job id) with the matrix values added in | |
| 44 | - | // parentheses when the name does not use the matrix context. env must | |
| 45 | - | // already include the matrix and strategy contexts, see [Strategy.MatrixEnv]. | |
| 46 | - | // | |
| 47 | - | // TODO(verify with recording): GitHub adds the values to the name unless the | |
| 48 | - | // name is set and refers to the matrix; here a name is considered to refer to | |
| 49 | - | // the matrix when its expression text contains "matrix.". | |
| 50 | - | func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) { | |
| 51 | - | name, err := template.EvaluateString(ctx, j.Name, env) | |
| 52 | - | if err != nil { | |
| 53 | - | return "", err | |
| 54 | - | } | |
| 55 | - | if !s.HasMatrix { | |
| 56 | - | return name, nil | |
| 57 | - | } | |
| 58 | - | if j.HasName && usesMatrix(j.Name) { | |
| 59 | - | return name, nil | |
| 60 | - | } | |
| 61 | - | return s.Combos[index].DisplayName(name), nil | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | func usesMatrix(t template.Token) bool { | |
| 65 | - | found := false | |
| 66 | - | template.Walk(t, func(x template.Token) bool { | |
| 67 | - | if be, ok := x.(*template.BasicExpression); ok && containsFold(be.Expression, "matrix.") { | |
| 68 | - | found = true | |
| 69 | - | } | |
| 70 | - | return !found | |
| 71 | - | }) | |
| 72 | - | return found | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func containsFold(s, sub string) bool { | |
| 76 | - | return strings.Contains(strings.ToLower(s), sub) | |
| 77 | - | } | |
| @@ -1,77 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: MIT | |||
| 2 | - | ||||
| 3 | - | package workflow | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "strings" | |||
| 8 | - | ||||
| 9 | - | "bnema.dev/ermite-actions/expr" | |||
| 10 | - | "bnema.dev/ermite-actions/template" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | // EvaluateStrategy evaluates the job's strategy token against env and | |||
| 14 | - | // expands its matrix. env must provide the contexts the schema allows in | |||
| 15 | - | // "strategy": github, inputs, vars and needs. A job without a strategy gives | |||
| 16 | - | // a Strategy with one empty combination. | |||
| 17 | - | func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) { | |||
| 18 | - | if j.Strategy == nil { | |||
| 19 | - | return ParseStrategy(nil) | |||
| 20 | - | } | |||
| 21 | - | v, err := template.Evaluate(ctx, j.Strategy, env) | |||
| 22 | - | if err != nil { | |||
| 23 | - | return nil, err | |||
| 24 | - | } | |||
| 25 | - | return ParseStrategy(v) | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | // MatrixEnv returns env with the "matrix" and "strategy" contexts of one | |||
| 29 | - | // combination added, for evaluating the parts of a job that may use them: | |||
| 30 | - | // runs-on, name, env, container, services, steps... | |||
| 31 | - | func (s *Strategy) MatrixEnv(env template.Env, index int) template.Env { | |||
| 32 | - | out := env | |||
| 33 | - | out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2) | |||
| 34 | - | for k, v := range env.Contexts { | |||
| 35 | - | out.Contexts[k] = v | |||
| 36 | - | } | |||
| 37 | - | out.Contexts["matrix"] = s.Combos[index].Values | |||
| 38 | - | out.Contexts["strategy"] = s.Context(index) | |||
| 39 | - | return out | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // DisplayName evaluates the job's name for combination index of its | |||
| 43 | - | // strategy: the name token (or the job id) with the matrix values added in | |||
| 44 | - | // parentheses when the name does not use the matrix context. env must | |||
| 45 | - | // already include the matrix and strategy contexts, see [Strategy.MatrixEnv]. | |||
| 46 | - | // | |||
| 47 | - | // TODO(verify with recording): GitHub adds the values to the name unless the | |||
| 48 | - | // name is set and refers to the matrix; here a name is considered to refer to | |||
| 49 | - | // the matrix when its expression text contains "matrix.". | |||
| 50 | - | func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) { | |||
| 51 | - | name, err := template.EvaluateString(ctx, j.Name, env) | |||
| 52 | - | if err != nil { | |||
| 53 | - | return "", err | |||
| 54 | - | } | |||
| 55 | - | if !s.HasMatrix { | |||
| 56 | - | return name, nil | |||
| 57 | - | } | |||
| 58 | - | if j.HasName && usesMatrix(j.Name) { | |||
| 59 | - | return name, nil | |||
| 60 | - | } | |||
| 61 | - | return s.Combos[index].DisplayName(name), nil | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | func usesMatrix(t template.Token) bool { | |||
| 65 | - | found := false | |||
| 66 | - | template.Walk(t, func(x template.Token) bool { | |||
| 67 | - | if be, ok := x.(*template.BasicExpression); ok && containsFold(be.Expression, "matrix.") { | |||
| 68 | - | found = true | |||
| 69 | - | } | |||
| 70 | - | return !found | |||
| 71 | - | }) | |||
| 72 | - | return found | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func containsFold(s, sub string) bool { | |||
| 76 | - | return strings.Contains(strings.ToLower(s), sub) | |||
| 77 | - | } | |||
M workflow/eval_test.go +15 -3
| @@ -65,7 +65,10 @@ jobs: | |||
| 65 | 65 | names := make([]string, len(s.Combos)) | |
| 66 | 66 | runsOn := make([]string, len(s.Combos)) | |
| 67 | 67 | for i := range s.Combos { | |
| 68 | - | e := s.MatrixEnv(env, i) | |
| 68 | + | e, err := s.MatrixEnv(env, i) | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 69 | 72 | names[i], err = job.DisplayName(context.Background(), e, s, i) | |
| 70 | 73 | if err != nil { | |
| 71 | 74 | t.Fatal(err) | |
| @@ -88,7 +91,7 @@ jobs: | |||
| 88 | 91 | // The strategy context. | |
| 89 | 92 | schema, _ := template.WorkflowSchema() | |
| 90 | 93 | e := template.NewExpression(template.Origin{}, "format('{0}/{1}', strategy.job-index, strategy.job-total)", schema.Info("runs-on")) | |
| 91 | - | if got, err := template.EvaluateString(context.Background(), e, s.MatrixEnv(env, 2)); err != nil || got != "2/4" { | |
| 94 | + | if got, err := template.EvaluateString(context.Background(), e, mustMatrixEnv(t, s, env, 2)); err != nil || got != "2/4" { | |
| 92 | 95 | t.Errorf("strategy context = %q, %v", got, err) | |
| 93 | 96 | } | |
| 94 | 97 | } | |
| @@ -113,7 +116,7 @@ jobs: | |||
| 113 | 116 | if err != nil { | |
| 114 | 117 | t.Fatal(err) | |
| 115 | 118 | } | |
| 116 | - | if got, _ := j.DisplayName(context.Background(), s.MatrixEnv(env, 1), s, 1); got != "build-m" { | |
| 119 | + | if got, _ := j.DisplayName(context.Background(), mustMatrixEnv(t, s, env, 1), s, 1); got != "build-m" { | |
| 117 | 120 | t.Errorf("name = %q", got) | |
| 118 | 121 | } | |
| 119 | 122 | b := w.Job("b") | |
| @@ -179,3 +182,12 @@ jobs: | |||
| 179 | 182 | t.Errorf("step ifs = %s, %s", b.Steps[0].If.Expression, b.Steps[1].If.Expression) | |
| 180 | 183 | } | |
| 181 | 184 | } | |
| 185 | + | ||
| 186 | + | func mustMatrixEnv(t testing.TB, s *Strategy, env template.Env, i int) template.Env { | |
| 187 | + | t.Helper() | |
| 188 | + | e, err := s.MatrixEnv(env, i) | |
| 189 | + | if err != nil { | |
| 190 | + | t.Fatal(err) | |
| 191 | + | } | |
| 192 | + | return e | |
| 193 | + | } | |
| @@ -65,7 +65,10 @@ jobs: | |||||
| 65 | names := make([]string, len(s.Combos)) | 65 | names := make([]string, len(s.Combos)) | ||
| 66 | runsOn := make([]string, len(s.Combos)) | 66 | runsOn := make([]string, len(s.Combos)) | ||
| 67 | for i := range s.Combos { | 67 | for i := range s.Combos { | ||
| 68 | - | e := s.MatrixEnv(env, i) | 68 | + | e, err := s.MatrixEnv(env, i) |
| 69 | + | if err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 69 | names[i], err = job.DisplayName(context.Background(), e, s, i) | 72 | names[i], err = job.DisplayName(context.Background(), e, s, i) | ||
| 70 | if err != nil { | 73 | if err != nil { | ||
| 71 | t.Fatal(err) | 74 | t.Fatal(err) | ||
| @@ -88,7 +91,7 @@ jobs: | |||||
| 88 | // The strategy context. | 91 | // The strategy context. | ||
| 89 | schema, _ := template.WorkflowSchema() | 92 | schema, _ := template.WorkflowSchema() | ||
| 90 | e := template.NewExpression(template.Origin{}, "format('{0}/{1}', strategy.job-index, strategy.job-total)", schema.Info("runs-on")) | 93 | e := template.NewExpression(template.Origin{}, "format('{0}/{1}', strategy.job-index, strategy.job-total)", schema.Info("runs-on")) | ||
| 91 | - | if got, err := template.EvaluateString(context.Background(), e, s.MatrixEnv(env, 2)); err != nil || got != "2/4" { | 94 | + | if got, err := template.EvaluateString(context.Background(), e, mustMatrixEnv(t, s, env, 2)); err != nil || got != "2/4" { |
| 92 | t.Errorf("strategy context = %q, %v", got, err) | 95 | t.Errorf("strategy context = %q, %v", got, err) | ||
| 93 | } | 96 | } | ||
| 94 | } | 97 | } | ||
| @@ -113,7 +116,7 @@ jobs: | |||||
| 113 | if err != nil { | 116 | if err != nil { | ||
| 114 | t.Fatal(err) | 117 | t.Fatal(err) | ||
| 115 | } | 118 | } | ||
| 116 | - | if got, _ := j.DisplayName(context.Background(), s.MatrixEnv(env, 1), s, 1); got != "build-m" { | 119 | + | if got, _ := j.DisplayName(context.Background(), mustMatrixEnv(t, s, env, 1), s, 1); got != "build-m" { |
| 117 | t.Errorf("name = %q", got) | 120 | t.Errorf("name = %q", got) | ||
| 118 | } | 121 | } | ||
| 119 | b := w.Job("b") | 122 | b := w.Job("b") | ||
| @@ -179,3 +182,12 @@ jobs: | |||||
| 179 | t.Errorf("step ifs = %s, %s", b.Steps[0].If.Expression, b.Steps[1].If.Expression) | 182 | t.Errorf("step ifs = %s, %s", b.Steps[0].If.Expression, b.Steps[1].If.Expression) | ||
| 180 | } | 183 | } | ||
| 181 | } | 184 | } | ||
| 185 | + | ||||
| 186 | + | func mustMatrixEnv(t testing.TB, s *Strategy, env template.Env, i int) template.Env { | |||
| 187 | + | t.Helper() | |||
| 188 | + | e, err := s.MatrixEnv(env, i) | |||
| 189 | + | if err != nil { | |||
| 190 | + | t.Fatal(err) | |||
| 191 | + | } | |||
| 192 | + | return e | |||
| 193 | + | } | |||
M workflow/fuzz_test.go +1 -1
| @@ -41,7 +41,7 @@ func FuzzExpandMatrix(f *testing.F) { | |||
| 41 | 41 | if v == nil { | |
| 42 | 42 | return | |
| 43 | 43 | } | |
| 44 | - | _, _ = ExpandMatrix(v) | |
| 44 | + | _, _ = ExpandMatrix(ctxbg, v) | |
| 45 | 45 | }) | |
| 46 | 46 | } | |
| 47 | 47 | ||
| @@ -41,7 +41,7 @@ func FuzzExpandMatrix(f *testing.F) { | |||||
| 41 | if v == nil { | 41 | if v == nil { | ||
| 42 | return | 42 | return | ||
| 43 | } | 43 | } | ||
| 44 | - | _, _ = ExpandMatrix(v) | 44 | + | _, _ = ExpandMatrix(ctxbg, v) |
| 45 | }) | 45 | }) | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
A workflow/job_eval.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "slices" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | "bnema.dev/ermite-actions/template" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // StatusFuncs is the state the status functions of conditions report: | |
| 16 | + | // success(), failure(), cancelled() and always(). | |
| 17 | + | type StatusFuncs struct { | |
| 18 | + | // Success is the result of success(): true while nothing before has | |
| 19 | + | // failed or been cancelled. | |
| 20 | + | Success bool | |
| 21 | + | // Failure is the result of failure(). | |
| 22 | + | Failure bool | |
| 23 | + | // Cancelled is the result of cancelled(). | |
| 24 | + | Cancelled bool | |
| 25 | + | // always() is true, whatever the state. | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Funcs returns the implementations to put in [template.Env.Funcs]. The | |
| 29 | + | // functions ignore their arguments. | |
| 30 | + | func (s StatusFuncs) Funcs() map[string]expr.Func { | |
| 31 | + | fixed := func(b bool) expr.Func { | |
| 32 | + | return func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(b), nil } | |
| 33 | + | } | |
| 34 | + | return map[string]expr.Func{ | |
| 35 | + | "success": fixed(s.Success), | |
| 36 | + | "failure": fixed(s.Failure), | |
| 37 | + | "cancelled": fixed(s.Cancelled), | |
| 38 | + | "always": fixed(true), | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // evaluateIf evaluates a condition with the status functions added to env and | |
| 43 | + | // converts the result to a boolean the way GitHub does (see expr.Truthy). | |
| 44 | + | func evaluateIf(ctx context.Context, cond *template.BasicExpression, env template.Env, st StatusFuncs) (bool, error) { | |
| 45 | + | out := env | |
| 46 | + | out.Funcs = make(map[string]expr.Func, len(env.Funcs)+4) | |
| 47 | + | for k, f := range env.Funcs { | |
| 48 | + | out.Funcs[k] = f | |
| 49 | + | } | |
| 50 | + | for k, f := range st.Funcs() { | |
| 51 | + | out.Funcs[k] = f | |
| 52 | + | } | |
| 53 | + | v, err := template.EvaluateExpression(ctx, cond, out) | |
| 54 | + | if err != nil { | |
| 55 | + | return false, err | |
| 56 | + | } | |
| 57 | + | return expr.Truthy(v), nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // EvaluateIf evaluates the job's condition. env must provide the contexts | |
| 61 | + | // the schema allows in a job's "if": github, inputs, vars and needs. | |
| 62 | + | func (j *Job) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) { | |
| 63 | + | return evaluateIf(ctx, j.If, env, st) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // EvaluateIf evaluates the step's condition. env must provide the contexts | |
| 67 | + | // the schema allows in a step's "if" and may provide hashFiles. | |
| 68 | + | func (s *Step) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) { | |
| 69 | + | return evaluateIf(ctx, s.If, env, st) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // EvaluateRunsOn evaluates the job's runs-on and returns the labels and the | |
| 73 | + | // runner group. runs-on may be a string, a list of strings, or a mapping with | |
| 74 | + | // "group" and "labels" (a string or a list), each part possibly an | |
| 75 | + | // expression. A job that calls a reusable workflow has no runs-on. | |
| 76 | + | // | |
| 77 | + | // The checks are those of the reference's converter: a group is a name, or | |
| 78 | + | // "org/name", "organization/name", "ent/name" or "enterprise/name". | |
| 79 | + | func (j *Job) EvaluateRunsOn(ctx context.Context, env template.Env) (labels []string, group string, err error) { | |
| 80 | + | if j.RunsOn == nil { | |
| 81 | + | return nil, "", fmt.Errorf("job '%s' has no runs-on", j.ID) | |
| 82 | + | } | |
| 83 | + | v, err := template.Evaluate(ctx, j.RunsOn, env) | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, "", err | |
| 86 | + | } | |
| 87 | + | fail := func(format string, a ...any) ([]string, string, error) { | |
| 88 | + | return nil, "", &template.Error{File: j.RunsOn.File(), Pos: j.RunsOn.Pos(), Raw: fmt.Sprintf(format, a...)} | |
| 89 | + | } | |
| 90 | + | switch v := v.(type) { | |
| 91 | + | case *expr.Dictionary: | |
| 92 | + | for _, p := range v.Pairs() { | |
| 93 | + | switch strings.ToLower(p.Key) { | |
| 94 | + | case "group": | |
| 95 | + | g, ok := p.Value.(expr.String) | |
| 96 | + | if !ok { | |
| 97 | + | return fail("Unexpected type '%s' encountered while reading 'job runs-on group name'. The type 'StringToken' was expected.", tokenKind(p.Value)) | |
| 98 | + | } | |
| 99 | + | if msg := checkGroup(string(g)); msg != "" { | |
| 100 | + | return fail("%s", msg) | |
| 101 | + | } | |
| 102 | + | group = string(g) | |
| 103 | + | case "labels": | |
| 104 | + | l, bad := labelList(p.Value) | |
| 105 | + | if bad != "" { | |
| 106 | + | return fail("%s", bad) | |
| 107 | + | } | |
| 108 | + | labels = append(labels, l...) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | return labels, group, nil | |
| 112 | + | } | |
| 113 | + | labels, bad := labelList(v) | |
| 114 | + | if bad != "" { | |
| 115 | + | return fail("%s", bad) | |
| 116 | + | } | |
| 117 | + | return labels, "", nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func tokenKind(v expr.Value) string { | |
| 121 | + | switch v.(type) { | |
| 122 | + | case expr.String: | |
| 123 | + | return template.KindString.String() | |
| 124 | + | case expr.Number: | |
| 125 | + | return template.KindNumber.String() | |
| 126 | + | case expr.Boolean: | |
| 127 | + | return template.KindBoolean.String() | |
| 128 | + | case *expr.Array: | |
| 129 | + | return template.KindSequence.String() | |
| 130 | + | case *expr.Dictionary: | |
| 131 | + | return template.KindMapping.String() | |
| 132 | + | } | |
| 133 | + | return template.KindNull.String() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // labelList reads a string or a list of strings. It returns the problem as a | |
| 137 | + | // message. | |
| 138 | + | func labelList(v expr.Value) ([]string, string) { | |
| 139 | + | switch v := v.(type) { | |
| 140 | + | case expr.String: | |
| 141 | + | return []string{string(v)}, "" | |
| 142 | + | case *expr.Array: | |
| 143 | + | var out []string | |
| 144 | + | for _, it := range v.Items() { | |
| 145 | + | s, ok := it.(expr.String) | |
| 146 | + | if !ok { | |
| 147 | + | return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(it)) | |
| 148 | + | } | |
| 149 | + | out = append(out, string(s)) | |
| 150 | + | } | |
| 151 | + | return out, "" | |
| 152 | + | } | |
| 153 | + | return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(v)) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // checkGroup returns the reference's message for an invalid runner group. | |
| 157 | + | func checkGroup(group string) string { | |
| 158 | + | names := strings.Split(group, "/") | |
| 159 | + | switch len(names) { | |
| 160 | + | case 1: | |
| 161 | + | return "" | |
| 162 | + | case 2: | |
| 163 | + | switch names[0] { | |
| 164 | + | case "org", "organization", "ent", "enterprise": | |
| 165 | + | default: | |
| 166 | + | return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |
| 167 | + | } | |
| 168 | + | if names[1] == "" { | |
| 169 | + | return fmt.Sprintf("Invalid runs-on group name '%s'.", group) | |
| 170 | + | } | |
| 171 | + | return "" | |
| 172 | + | } | |
| 173 | + | return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // JobLevels groups the jobs by dependency depth: the first level holds the | |
| 177 | + | // jobs without needs, and every later job is in the level after the last of | |
| 178 | + | // its dependencies. Within a level the jobs keep the order of the file. A | |
| 179 | + | // job whose needs name an unknown job, or that is part of a cycle, is left | |
| 180 | + | // out; Parse rejects such workflows, so this only happens for a Workflow | |
| 181 | + | // built by hand. | |
| 182 | + | func (w *Workflow) JobLevels() [][]*Job { | |
| 183 | + | level := make(map[string]int, len(w.Jobs)) | |
| 184 | + | var levels [][]*Job | |
| 185 | + | placed := 0 | |
| 186 | + | for placed < len(w.Jobs) { | |
| 187 | + | progressed := false | |
| 188 | + | for _, j := range w.Jobs { | |
| 189 | + | if _, done := level[j.ID]; done { | |
| 190 | + | continue | |
| 191 | + | } | |
| 192 | + | depth, ready := 0, true | |
| 193 | + | for _, n := range j.Needs { | |
| 194 | + | l, ok := level[n] | |
| 195 | + | if !ok { | |
| 196 | + | ready = false | |
| 197 | + | break | |
| 198 | + | } | |
| 199 | + | depth = max(depth, l+1) | |
| 200 | + | } | |
| 201 | + | if !ready { | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | level[j.ID] = depth | |
| 205 | + | for len(levels) <= depth { | |
| 206 | + | levels = append(levels, nil) | |
| 207 | + | } | |
| 208 | + | levels[depth] = append(levels[depth], j) | |
| 209 | + | placed++ | |
| 210 | + | progressed = true | |
| 211 | + | } | |
| 212 | + | if !progressed { | |
| 213 | + | break | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | // A job placed in a later pass of the file may belong to an earlier level | |
| 217 | + | // than one placed before it; restore file order inside each level. | |
| 218 | + | pos := make(map[*Job]int, len(w.Jobs)) | |
| 219 | + | for i, j := range w.Jobs { | |
| 220 | + | pos[j] = i | |
| 221 | + | } | |
| 222 | + | for _, l := range levels { | |
| 223 | + | slices.SortFunc(l, func(a, b *Job) int { return pos[a] - pos[b] }) | |
| 224 | + | } | |
| 225 | + | return levels | |
| 226 | + | } | |
| @@ -0,0 +1,226 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "slices" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite-actions/expr" | |||
| 12 | + | "bnema.dev/ermite-actions/template" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // StatusFuncs is the state the status functions of conditions report: | |||
| 16 | + | // success(), failure(), cancelled() and always(). | |||
| 17 | + | type StatusFuncs struct { | |||
| 18 | + | // Success is the result of success(): true while nothing before has | |||
| 19 | + | // failed or been cancelled. | |||
| 20 | + | Success bool | |||
| 21 | + | // Failure is the result of failure(). | |||
| 22 | + | Failure bool | |||
| 23 | + | // Cancelled is the result of cancelled(). | |||
| 24 | + | Cancelled bool | |||
| 25 | + | // always() is true, whatever the state. | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Funcs returns the implementations to put in [template.Env.Funcs]. The | |||
| 29 | + | // functions ignore their arguments. | |||
| 30 | + | func (s StatusFuncs) Funcs() map[string]expr.Func { | |||
| 31 | + | fixed := func(b bool) expr.Func { | |||
| 32 | + | return func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(b), nil } | |||
| 33 | + | } | |||
| 34 | + | return map[string]expr.Func{ | |||
| 35 | + | "success": fixed(s.Success), | |||
| 36 | + | "failure": fixed(s.Failure), | |||
| 37 | + | "cancelled": fixed(s.Cancelled), | |||
| 38 | + | "always": fixed(true), | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // evaluateIf evaluates a condition with the status functions added to env and | |||
| 43 | + | // converts the result to a boolean the way GitHub does (see expr.Truthy). | |||
| 44 | + | func evaluateIf(ctx context.Context, cond *template.BasicExpression, env template.Env, st StatusFuncs) (bool, error) { | |||
| 45 | + | out := env | |||
| 46 | + | out.Funcs = make(map[string]expr.Func, len(env.Funcs)+4) | |||
| 47 | + | for k, f := range env.Funcs { | |||
| 48 | + | out.Funcs[k] = f | |||
| 49 | + | } | |||
| 50 | + | for k, f := range st.Funcs() { | |||
| 51 | + | out.Funcs[k] = f | |||
| 52 | + | } | |||
| 53 | + | v, err := template.EvaluateExpression(ctx, cond, out) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return false, err | |||
| 56 | + | } | |||
| 57 | + | return expr.Truthy(v), nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // EvaluateIf evaluates the job's condition. env must provide the contexts | |||
| 61 | + | // the schema allows in a job's "if": github, inputs, vars and needs. | |||
| 62 | + | func (j *Job) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) { | |||
| 63 | + | return evaluateIf(ctx, j.If, env, st) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // EvaluateIf evaluates the step's condition. env must provide the contexts | |||
| 67 | + | // the schema allows in a step's "if" and may provide hashFiles. | |||
| 68 | + | func (s *Step) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) { | |||
| 69 | + | return evaluateIf(ctx, s.If, env, st) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // EvaluateRunsOn evaluates the job's runs-on and returns the labels and the | |||
| 73 | + | // runner group. runs-on may be a string, a list of strings, or a mapping with | |||
| 74 | + | // "group" and "labels" (a string or a list), each part possibly an | |||
| 75 | + | // expression. A job that calls a reusable workflow has no runs-on. | |||
| 76 | + | // | |||
| 77 | + | // The checks are those of the reference's converter: a group is a name, or | |||
| 78 | + | // "org/name", "organization/name", "ent/name" or "enterprise/name". | |||
| 79 | + | func (j *Job) EvaluateRunsOn(ctx context.Context, env template.Env) (labels []string, group string, err error) { | |||
| 80 | + | if j.RunsOn == nil { | |||
| 81 | + | return nil, "", fmt.Errorf("job '%s' has no runs-on", j.ID) | |||
| 82 | + | } | |||
| 83 | + | v, err := template.Evaluate(ctx, j.RunsOn, env) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return nil, "", err | |||
| 86 | + | } | |||
| 87 | + | fail := func(format string, a ...any) ([]string, string, error) { | |||
| 88 | + | return nil, "", &template.Error{File: j.RunsOn.File(), Pos: j.RunsOn.Pos(), Raw: fmt.Sprintf(format, a...)} | |||
| 89 | + | } | |||
| 90 | + | switch v := v.(type) { | |||
| 91 | + | case *expr.Dictionary: | |||
| 92 | + | for _, p := range v.Pairs() { | |||
| 93 | + | switch strings.ToLower(p.Key) { | |||
| 94 | + | case "group": | |||
| 95 | + | g, ok := p.Value.(expr.String) | |||
| 96 | + | if !ok { | |||
| 97 | + | return fail("Unexpected type '%s' encountered while reading 'job runs-on group name'. The type 'StringToken' was expected.", tokenKind(p.Value)) | |||
| 98 | + | } | |||
| 99 | + | if msg := checkGroup(string(g)); msg != "" { | |||
| 100 | + | return fail("%s", msg) | |||
| 101 | + | } | |||
| 102 | + | group = string(g) | |||
| 103 | + | case "labels": | |||
| 104 | + | l, bad := labelList(p.Value) | |||
| 105 | + | if bad != "" { | |||
| 106 | + | return fail("%s", bad) | |||
| 107 | + | } | |||
| 108 | + | labels = append(labels, l...) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | return labels, group, nil | |||
| 112 | + | } | |||
| 113 | + | labels, bad := labelList(v) | |||
| 114 | + | if bad != "" { | |||
| 115 | + | return fail("%s", bad) | |||
| 116 | + | } | |||
| 117 | + | return labels, "", nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func tokenKind(v expr.Value) string { | |||
| 121 | + | switch v.(type) { | |||
| 122 | + | case expr.String: | |||
| 123 | + | return template.KindString.String() | |||
| 124 | + | case expr.Number: | |||
| 125 | + | return template.KindNumber.String() | |||
| 126 | + | case expr.Boolean: | |||
| 127 | + | return template.KindBoolean.String() | |||
| 128 | + | case *expr.Array: | |||
| 129 | + | return template.KindSequence.String() | |||
| 130 | + | case *expr.Dictionary: | |||
| 131 | + | return template.KindMapping.String() | |||
| 132 | + | } | |||
| 133 | + | return template.KindNull.String() | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // labelList reads a string or a list of strings. It returns the problem as a | |||
| 137 | + | // message. | |||
| 138 | + | func labelList(v expr.Value) ([]string, string) { | |||
| 139 | + | switch v := v.(type) { | |||
| 140 | + | case expr.String: | |||
| 141 | + | return []string{string(v)}, "" | |||
| 142 | + | case *expr.Array: | |||
| 143 | + | var out []string | |||
| 144 | + | for _, it := range v.Items() { | |||
| 145 | + | s, ok := it.(expr.String) | |||
| 146 | + | if !ok { | |||
| 147 | + | return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(it)) | |||
| 148 | + | } | |||
| 149 | + | out = append(out, string(s)) | |||
| 150 | + | } | |||
| 151 | + | return out, "" | |||
| 152 | + | } | |||
| 153 | + | return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(v)) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // checkGroup returns the reference's message for an invalid runner group. | |||
| 157 | + | func checkGroup(group string) string { | |||
| 158 | + | names := strings.Split(group, "/") | |||
| 159 | + | switch len(names) { | |||
| 160 | + | case 1: | |||
| 161 | + | return "" | |||
| 162 | + | case 2: | |||
| 163 | + | switch names[0] { | |||
| 164 | + | case "org", "organization", "ent", "enterprise": | |||
| 165 | + | default: | |||
| 166 | + | return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |||
| 167 | + | } | |||
| 168 | + | if names[1] == "" { | |||
| 169 | + | return fmt.Sprintf("Invalid runs-on group name '%s'.", group) | |||
| 170 | + | } | |||
| 171 | + | return "" | |||
| 172 | + | } | |||
| 173 | + | return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // JobLevels groups the jobs by dependency depth: the first level holds the | |||
| 177 | + | // jobs without needs, and every later job is in the level after the last of | |||
| 178 | + | // its dependencies. Within a level the jobs keep the order of the file. A | |||
| 179 | + | // job whose needs name an unknown job, or that is part of a cycle, is left | |||
| 180 | + | // out; Parse rejects such workflows, so this only happens for a Workflow | |||
| 181 | + | // built by hand. | |||
| 182 | + | func (w *Workflow) JobLevels() [][]*Job { | |||
| 183 | + | level := make(map[string]int, len(w.Jobs)) | |||
| 184 | + | var levels [][]*Job | |||
| 185 | + | placed := 0 | |||
| 186 | + | for placed < len(w.Jobs) { | |||
| 187 | + | progressed := false | |||
| 188 | + | for _, j := range w.Jobs { | |||
| 189 | + | if _, done := level[j.ID]; done { | |||
| 190 | + | continue | |||
| 191 | + | } | |||
| 192 | + | depth, ready := 0, true | |||
| 193 | + | for _, n := range j.Needs { | |||
| 194 | + | l, ok := level[n] | |||
| 195 | + | if !ok { | |||
| 196 | + | ready = false | |||
| 197 | + | break | |||
| 198 | + | } | |||
| 199 | + | depth = max(depth, l+1) | |||
| 200 | + | } | |||
| 201 | + | if !ready { | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 204 | + | level[j.ID] = depth | |||
| 205 | + | for len(levels) <= depth { | |||
| 206 | + | levels = append(levels, nil) | |||
| 207 | + | } | |||
| 208 | + | levels[depth] = append(levels[depth], j) | |||
| 209 | + | placed++ | |||
| 210 | + | progressed = true | |||
| 211 | + | } | |||
| 212 | + | if !progressed { | |||
| 213 | + | break | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | // A job placed in a later pass of the file may belong to an earlier level | |||
| 217 | + | // than one placed before it; restore file order inside each level. | |||
| 218 | + | pos := make(map[*Job]int, len(w.Jobs)) | |||
| 219 | + | for i, j := range w.Jobs { | |||
| 220 | + | pos[j] = i | |||
| 221 | + | } | |||
| 222 | + | for _, l := range levels { | |||
| 223 | + | slices.SortFunc(l, func(a, b *Job) int { return pos[a] - pos[b] }) | |||
| 224 | + | } | |||
| 225 | + | return levels | |||
| 226 | + | } | |||
A workflow/job_eval_test.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestEvaluateRunsOn(t *testing.T) { | |
| 11 | + | w := mustParse(t, `on: push | |
| 12 | + | jobs: | |
| 13 | + | s: | |
| 14 | + | runs-on: ubuntu-latest | |
| 15 | + | steps: [{run: a}] | |
| 16 | + | l: | |
| 17 | + | runs-on: [self-hosted, "${{ matrix.arch }}"] | |
| 18 | + | steps: [{run: a}] | |
| 19 | + | m: | |
| 20 | + | runs-on: | |
| 21 | + | group: org/big | |
| 22 | + | labels: ${{ fromJSON('["a","b"]') }} | |
| 23 | + | steps: [{run: a}] | |
| 24 | + | e: | |
| 25 | + | runs-on: ${{ matrix.os }} | |
| 26 | + | steps: [{run: a}] | |
| 27 | + | g: | |
| 28 | + | runs-on: | |
| 29 | + | group: ${{ matrix.g }} | |
| 30 | + | steps: [{run: a}] | |
| 31 | + | r: | |
| 32 | + | uses: ./x.yml | |
| 33 | + | `) | |
| 34 | + | env := envWith(t, map[string]string{"matrix": `{"arch": "arm64", "os": "linux", "g": "bogus/x", "n": 1}`}) | |
| 35 | + | tests := []struct { | |
| 36 | + | job, labels, group, err string | |
| 37 | + | }{ | |
| 38 | + | {"s", "ubuntu-latest", "", ""}, | |
| 39 | + | {"l", "self-hosted,arm64", "", ""}, | |
| 40 | + | {"m", "a,b", "org/big", ""}, | |
| 41 | + | {"e", "linux", "", ""}, | |
| 42 | + | {"g", "", "", "Invalid runs-on group name 'bogus/x. Please use"}, | |
| 43 | + | {"r", "", "", "has no runs-on"}, | |
| 44 | + | } | |
| 45 | + | for _, tc := range tests { | |
| 46 | + | labels, group, err := w.Job(tc.job).EvaluateRunsOn(ctxbg, env) | |
| 47 | + | if tc.err != "" { | |
| 48 | + | if err == nil || !strings.Contains(err.Error(), tc.err) { | |
| 49 | + | t.Errorf("%s: err = %v, want %q", tc.job, err, tc.err) | |
| 50 | + | } | |
| 51 | + | continue | |
| 52 | + | } | |
| 53 | + | if err != nil || strings.Join(labels, ",") != tc.labels || group != tc.group { | |
| 54 | + | t.Errorf("%s: %q, %q, %v", tc.job, labels, group, err) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | // Non-string results. | |
| 58 | + | bad := mustParse(t, "on: push\njobs:\n a:\n runs-on: ${{ matrix.n }}\n steps: [{run: a}]\n b:\n runs-on: [\"${{ matrix.n }}\"]\n steps: [{run: a}]\n") | |
| 59 | + | for _, id := range []string{"a", "b"} { | |
| 60 | + | _, _, err := bad.Job(id).EvaluateRunsOn(ctxbg, env) | |
| 61 | + | if err == nil || !strings.Contains(err.Error(), "Unexpected type") { | |
| 62 | + | t.Errorf("%s: err = %v", id, err) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestEvaluateIf(t *testing.T) { | |
| 68 | + | w := mustParse(t, `on: push | |
| 69 | + | jobs: | |
| 70 | + | a: | |
| 71 | + | runs-on: x | |
| 72 | + | steps: [{run: a}] | |
| 73 | + | b: | |
| 74 | + | needs: a | |
| 75 | + | if: needs.a.result == 'failure' | |
| 76 | + | runs-on: x | |
| 77 | + | steps: | |
| 78 | + | - run: a | |
| 79 | + | if: failure() | |
| 80 | + | - run: b | |
| 81 | + | - run: c | |
| 82 | + | if: always() | |
| 83 | + | c: | |
| 84 | + | needs: a | |
| 85 | + | if: ${{ always() && !cancelled() }} | |
| 86 | + | runs-on: x | |
| 87 | + | steps: [{run: a}] | |
| 88 | + | `) | |
| 89 | + | env := envWith(t, map[string]string{"needs": `{"a": {"result": "failure"}}`}) | |
| 90 | + | b := w.Job("b") | |
| 91 | + | tests := []struct { | |
| 92 | + | name string | |
| 93 | + | got func() (bool, error) | |
| 94 | + | want bool | |
| 95 | + | }{ | |
| 96 | + | {"job, success", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true}, | |
| 97 | + | {"job, after failure", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false}, | |
| 98 | + | {"step failure()", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true}, | |
| 99 | + | {"step failure() when ok", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, false}, | |
| 100 | + | {"step default", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true}, | |
| 101 | + | {"step default after failure", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false}, | |
| 102 | + | {"always", func() (bool, error) { return b.Steps[2].EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, true}, | |
| 103 | + | {"always and not cancelled", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, false}, | |
| 104 | + | {"always and not cancelled, ok", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true}, | |
| 105 | + | } | |
| 106 | + | for _, tc := range tests { | |
| 107 | + | got, err := tc.got() | |
| 108 | + | if err != nil || got != tc.want { | |
| 109 | + | t.Errorf("%s = %v, %v; want %v", tc.name, got, err, tc.want) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | // The caller's functions (hashFiles) are kept, and env is not modified. | |
| 113 | + | env.Funcs = nil | |
| 114 | + | if _, err := b.EvaluateIf(ctxbg, env, StatusFuncs{}); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | if env.Funcs != nil { | |
| 118 | + | t.Error("env.Funcs was modified") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestJobLevels(t *testing.T) { | |
| 123 | + | w := mustParse(t, `on: push | |
| 124 | + | jobs: | |
| 125 | + | deploy: | |
| 126 | + | needs: [test, lint] | |
| 127 | + | runs-on: x | |
| 128 | + | steps: [{run: a}] | |
| 129 | + | test: | |
| 130 | + | needs: build | |
| 131 | + | runs-on: x | |
| 132 | + | steps: [{run: a}] | |
| 133 | + | lint: | |
| 134 | + | runs-on: x | |
| 135 | + | steps: [{run: a}] | |
| 136 | + | build: | |
| 137 | + | runs-on: x | |
| 138 | + | steps: [{run: a}] | |
| 139 | + | docs: | |
| 140 | + | needs: [build] | |
| 141 | + | runs-on: x | |
| 142 | + | steps: [{run: a}] | |
| 143 | + | notify: | |
| 144 | + | needs: [deploy, docs] | |
| 145 | + | runs-on: x | |
| 146 | + | steps: [{run: a}] | |
| 147 | + | `) | |
| 148 | + | var got []string | |
| 149 | + | for _, l := range w.JobLevels() { | |
| 150 | + | var ids []string | |
| 151 | + | for _, j := range l { | |
| 152 | + | ids = append(ids, j.ID) | |
| 153 | + | } | |
| 154 | + | got = append(got, strings.Join(ids, ",")) | |
| 155 | + | } | |
| 156 | + | if want := "lint,build|test,docs|deploy|notify"; strings.Join(got, "|") != want { | |
| 157 | + | t.Errorf("levels = %v, want %s", got, want) | |
| 158 | + | } | |
| 159 | + | // Stable across calls; a hand-built cycle is left out, not looped on. | |
| 160 | + | cyc := &Workflow{Jobs: []*Job{{ID: "a", Needs: []string{"b"}}, {ID: "b", Needs: []string{"a"}}, {ID: "c"}, {ID: "d", Needs: []string{"zz"}}}} | |
| 161 | + | l := cyc.JobLevels() | |
| 162 | + | if len(l) != 1 || len(l[0]) != 1 || l[0][0].ID != "c" { | |
| 163 | + | t.Errorf("cycle levels = %v", l) | |
| 164 | + | } | |
| 165 | + | if len((&Workflow{}).JobLevels()) != 0 { | |
| 166 | + | t.Error("empty workflow has levels") | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestDisplayNameAstWalk(t *testing.T) { | |
| 171 | + | // The word "matrix." in a string literal or another context is not a use | |
| 172 | + | // of the matrix context. | |
| 173 | + | w := mustParse(t, `on: push | |
| 174 | + | jobs: | |
| 175 | + | a: | |
| 176 | + | name: "${{ github.event.matrix }} and matrix. text" | |
| 177 | + | runs-on: x | |
| 178 | + | strategy: | |
| 179 | + | matrix: | |
| 180 | + | os: [l] | |
| 181 | + | steps: [{run: a}] | |
| 182 | + | b: | |
| 183 | + | name: ${{ format('{0}', matrix.os) }} | |
| 184 | + | runs-on: x | |
| 185 | + | strategy: | |
| 186 | + | matrix: | |
| 187 | + | os: [l] | |
| 188 | + | steps: [{run: a}] | |
| 189 | + | `) | |
| 190 | + | env := envWith(t, map[string]string{"github": `{"event": {"matrix": "m"}}`}) | |
| 191 | + | for id, want := range map[string]string{"a": "m and matrix. text (l)", "b": "l"} { | |
| 192 | + | j := w.Job(id) | |
| 193 | + | s, err := j.EvaluateStrategy(ctxbg, env) | |
| 194 | + | if err != nil { | |
| 195 | + | t.Fatal(err) | |
| 196 | + | } | |
| 197 | + | got, err := j.DisplayName(ctxbg, mustMatrixEnv(t, s, env, 0), s, 0) | |
| 198 | + | if err != nil || got != want { | |
| 199 | + | t.Errorf("%s: name = %q, %v; want %q", id, got, err, want) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| @@ -0,0 +1,202 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestEvaluateRunsOn(t *testing.T) { | |||
| 11 | + | w := mustParse(t, `on: push | |||
| 12 | + | jobs: | |||
| 13 | + | s: | |||
| 14 | + | runs-on: ubuntu-latest | |||
| 15 | + | steps: [{run: a}] | |||
| 16 | + | l: | |||
| 17 | + | runs-on: [self-hosted, "${{ matrix.arch }}"] | |||
| 18 | + | steps: [{run: a}] | |||
| 19 | + | m: | |||
| 20 | + | runs-on: | |||
| 21 | + | group: org/big | |||
| 22 | + | labels: ${{ fromJSON('["a","b"]') }} | |||
| 23 | + | steps: [{run: a}] | |||
| 24 | + | e: | |||
| 25 | + | runs-on: ${{ matrix.os }} | |||
| 26 | + | steps: [{run: a}] | |||
| 27 | + | g: | |||
| 28 | + | runs-on: | |||
| 29 | + | group: ${{ matrix.g }} | |||
| 30 | + | steps: [{run: a}] | |||
| 31 | + | r: | |||
| 32 | + | uses: ./x.yml | |||
| 33 | + | `) | |||
| 34 | + | env := envWith(t, map[string]string{"matrix": `{"arch": "arm64", "os": "linux", "g": "bogus/x", "n": 1}`}) | |||
| 35 | + | tests := []struct { | |||
| 36 | + | job, labels, group, err string | |||
| 37 | + | }{ | |||
| 38 | + | {"s", "ubuntu-latest", "", ""}, | |||
| 39 | + | {"l", "self-hosted,arm64", "", ""}, | |||
| 40 | + | {"m", "a,b", "org/big", ""}, | |||
| 41 | + | {"e", "linux", "", ""}, | |||
| 42 | + | {"g", "", "", "Invalid runs-on group name 'bogus/x. Please use"}, | |||
| 43 | + | {"r", "", "", "has no runs-on"}, | |||
| 44 | + | } | |||
| 45 | + | for _, tc := range tests { | |||
| 46 | + | labels, group, err := w.Job(tc.job).EvaluateRunsOn(ctxbg, env) | |||
| 47 | + | if tc.err != "" { | |||
| 48 | + | if err == nil || !strings.Contains(err.Error(), tc.err) { | |||
| 49 | + | t.Errorf("%s: err = %v, want %q", tc.job, err, tc.err) | |||
| 50 | + | } | |||
| 51 | + | continue | |||
| 52 | + | } | |||
| 53 | + | if err != nil || strings.Join(labels, ",") != tc.labels || group != tc.group { | |||
| 54 | + | t.Errorf("%s: %q, %q, %v", tc.job, labels, group, err) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | // Non-string results. | |||
| 58 | + | bad := mustParse(t, "on: push\njobs:\n a:\n runs-on: ${{ matrix.n }}\n steps: [{run: a}]\n b:\n runs-on: [\"${{ matrix.n }}\"]\n steps: [{run: a}]\n") | |||
| 59 | + | for _, id := range []string{"a", "b"} { | |||
| 60 | + | _, _, err := bad.Job(id).EvaluateRunsOn(ctxbg, env) | |||
| 61 | + | if err == nil || !strings.Contains(err.Error(), "Unexpected type") { | |||
| 62 | + | t.Errorf("%s: err = %v", id, err) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestEvaluateIf(t *testing.T) { | |||
| 68 | + | w := mustParse(t, `on: push | |||
| 69 | + | jobs: | |||
| 70 | + | a: | |||
| 71 | + | runs-on: x | |||
| 72 | + | steps: [{run: a}] | |||
| 73 | + | b: | |||
| 74 | + | needs: a | |||
| 75 | + | if: needs.a.result == 'failure' | |||
| 76 | + | runs-on: x | |||
| 77 | + | steps: | |||
| 78 | + | - run: a | |||
| 79 | + | if: failure() | |||
| 80 | + | - run: b | |||
| 81 | + | - run: c | |||
| 82 | + | if: always() | |||
| 83 | + | c: | |||
| 84 | + | needs: a | |||
| 85 | + | if: ${{ always() && !cancelled() }} | |||
| 86 | + | runs-on: x | |||
| 87 | + | steps: [{run: a}] | |||
| 88 | + | `) | |||
| 89 | + | env := envWith(t, map[string]string{"needs": `{"a": {"result": "failure"}}`}) | |||
| 90 | + | b := w.Job("b") | |||
| 91 | + | tests := []struct { | |||
| 92 | + | name string | |||
| 93 | + | got func() (bool, error) | |||
| 94 | + | want bool | |||
| 95 | + | }{ | |||
| 96 | + | {"job, success", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true}, | |||
| 97 | + | {"job, after failure", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false}, | |||
| 98 | + | {"step failure()", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true}, | |||
| 99 | + | {"step failure() when ok", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, false}, | |||
| 100 | + | {"step default", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true}, | |||
| 101 | + | {"step default after failure", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false}, | |||
| 102 | + | {"always", func() (bool, error) { return b.Steps[2].EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, true}, | |||
| 103 | + | {"always and not cancelled", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, false}, | |||
| 104 | + | {"always and not cancelled, ok", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true}, | |||
| 105 | + | } | |||
| 106 | + | for _, tc := range tests { | |||
| 107 | + | got, err := tc.got() | |||
| 108 | + | if err != nil || got != tc.want { | |||
| 109 | + | t.Errorf("%s = %v, %v; want %v", tc.name, got, err, tc.want) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | // The caller's functions (hashFiles) are kept, and env is not modified. | |||
| 113 | + | env.Funcs = nil | |||
| 114 | + | if _, err := b.EvaluateIf(ctxbg, env, StatusFuncs{}); err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | if env.Funcs != nil { | |||
| 118 | + | t.Error("env.Funcs was modified") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestJobLevels(t *testing.T) { | |||
| 123 | + | w := mustParse(t, `on: push | |||
| 124 | + | jobs: | |||
| 125 | + | deploy: | |||
| 126 | + | needs: [test, lint] | |||
| 127 | + | runs-on: x | |||
| 128 | + | steps: [{run: a}] | |||
| 129 | + | test: | |||
| 130 | + | needs: build | |||
| 131 | + | runs-on: x | |||
| 132 | + | steps: [{run: a}] | |||
| 133 | + | lint: | |||
| 134 | + | runs-on: x | |||
| 135 | + | steps: [{run: a}] | |||
| 136 | + | build: | |||
| 137 | + | runs-on: x | |||
| 138 | + | steps: [{run: a}] | |||
| 139 | + | docs: | |||
| 140 | + | needs: [build] | |||
| 141 | + | runs-on: x | |||
| 142 | + | steps: [{run: a}] | |||
| 143 | + | notify: | |||
| 144 | + | needs: [deploy, docs] | |||
| 145 | + | runs-on: x | |||
| 146 | + | steps: [{run: a}] | |||
| 147 | + | `) | |||
| 148 | + | var got []string | |||
| 149 | + | for _, l := range w.JobLevels() { | |||
| 150 | + | var ids []string | |||
| 151 | + | for _, j := range l { | |||
| 152 | + | ids = append(ids, j.ID) | |||
| 153 | + | } | |||
| 154 | + | got = append(got, strings.Join(ids, ",")) | |||
| 155 | + | } | |||
| 156 | + | if want := "lint,build|test,docs|deploy|notify"; strings.Join(got, "|") != want { | |||
| 157 | + | t.Errorf("levels = %v, want %s", got, want) | |||
| 158 | + | } | |||
| 159 | + | // Stable across calls; a hand-built cycle is left out, not looped on. | |||
| 160 | + | cyc := &Workflow{Jobs: []*Job{{ID: "a", Needs: []string{"b"}}, {ID: "b", Needs: []string{"a"}}, {ID: "c"}, {ID: "d", Needs: []string{"zz"}}}} | |||
| 161 | + | l := cyc.JobLevels() | |||
| 162 | + | if len(l) != 1 || len(l[0]) != 1 || l[0][0].ID != "c" { | |||
| 163 | + | t.Errorf("cycle levels = %v", l) | |||
| 164 | + | } | |||
| 165 | + | if len((&Workflow{}).JobLevels()) != 0 { | |||
| 166 | + | t.Error("empty workflow has levels") | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestDisplayNameAstWalk(t *testing.T) { | |||
| 171 | + | // The word "matrix." in a string literal or another context is not a use | |||
| 172 | + | // of the matrix context. | |||
| 173 | + | w := mustParse(t, `on: push | |||
| 174 | + | jobs: | |||
| 175 | + | a: | |||
| 176 | + | name: "${{ github.event.matrix }} and matrix. text" | |||
| 177 | + | runs-on: x | |||
| 178 | + | strategy: | |||
| 179 | + | matrix: | |||
| 180 | + | os: [l] | |||
| 181 | + | steps: [{run: a}] | |||
| 182 | + | b: | |||
| 183 | + | name: ${{ format('{0}', matrix.os) }} | |||
| 184 | + | runs-on: x | |||
| 185 | + | strategy: | |||
| 186 | + | matrix: | |||
| 187 | + | os: [l] | |||
| 188 | + | steps: [{run: a}] | |||
| 189 | + | `) | |||
| 190 | + | env := envWith(t, map[string]string{"github": `{"event": {"matrix": "m"}}`}) | |||
| 191 | + | for id, want := range map[string]string{"a": "m and matrix. text (l)", "b": "l"} { | |||
| 192 | + | j := w.Job(id) | |||
| 193 | + | s, err := j.EvaluateStrategy(ctxbg, env) | |||
| 194 | + | if err != nil { | |||
| 195 | + | t.Fatal(err) | |||
| 196 | + | } | |||
| 197 | + | got, err := j.DisplayName(ctxbg, mustMatrixEnv(t, s, env, 0), s, 0) | |||
| 198 | + | if err != nil || got != want { | |||
| 199 | + | t.Errorf("%s: name = %q, %v; want %q", id, got, err, want) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
M workflow/matrix.go +159 -212
| @@ -3,21 +3,29 @@ | |||
| 3 | 3 | package workflow | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | - | "strconv" | |
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | 11 | "bnema.dev/ermite-actions/expr" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | - | // MaxMatrixJobs is the number of jobs a matrix may produce. | |
| 15 | - | const MaxMatrixJobs = 256 | |
| 14 | + | // Limits of matrix expansion. GitHub only limits the number of jobs; the | |
| 15 | + | // other limits keep the work done for a hostile matrix bounded. | |
| 16 | + | const ( | |
| 17 | + | // MaxMatrixJobs is the number of jobs a matrix may produce. | |
| 18 | + | MaxMatrixJobs = 256 | |
| 19 | + | // MaxMatrixProduct is the size of the cross product of the vectors, before | |
| 20 | + | // exclusions, above which a matrix is refused. | |
| 21 | + | MaxMatrixProduct = 1 << 16 | |
| 22 | + | // MaxMatrixEntries is the number of entries of include and, separately, | |
| 23 | + | // of exclude. | |
| 24 | + | MaxMatrixEntries = 256 | |
| 25 | + | ) | |
| 16 | 26 | ||
| 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 | |
| 27 | + | // checkEvery is the number of combinations between two checks of the context. | |
| 28 | + | const checkEvery = 1024 | |
| 21 | 29 | ||
| 22 | 30 | // MatrixCombo is one job of a matrix. | |
| 23 | 31 | type MatrixCombo struct { | |
| @@ -27,7 +35,7 @@ type MatrixCombo struct { | |||
| 27 | 35 | Values *expr.Dictionary | |
| 28 | 36 | // Segments are the scalar values of Values, in order, shown in the name | |
| 29 | 37 | // of the job: "build (ubuntu-latest, 14)". Arrays and mappings are left | |
| 30 | - | // out. | |
| 38 | + | // out; null shows as an empty segment. | |
| 31 | 39 | Segments []string | |
| 32 | 40 | } | |
| 33 | 41 | ||
| @@ -40,19 +48,6 @@ func (c MatrixCombo) DisplayName(base string) string { | |||
| 40 | 48 | return base + " (" + strings.Join(c.Segments, ", ") + ")" | |
| 41 | 49 | } | |
| 42 | 50 | ||
| 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 | 51 | // ExpandMatrix expands the matrix of an evaluated strategy (a dictionary with | |
| 57 | 52 | // the optional keys "matrix", "fail-fast" and "max-parallel", or null) into | |
| 58 | 53 | // the job combinations, like GitHub: | |
| @@ -60,7 +55,8 @@ type Strategy struct { | |||
| 60 | 55 | // - the combinations are the cross product of the vectors, the first key | |
| 61 | 56 | // varying slowest; | |
| 62 | 57 | // - exclude removes every combination that contains all the key-value | |
| 63 | - | // pairs of one of its entries; | |
| 58 | + | // pairs of one of its entries; every key of an entry must be a vector of | |
| 59 | + | // the matrix, or the matrix is an error; | |
| 64 | 60 | // - then each include entry is added to every remaining combination whose | |
| 65 | 61 | // values for the vector keys it mentions are equal (an entry that | |
| 66 | 62 | // mentions no vector key matches all), without overwriting the original | |
| @@ -69,118 +65,29 @@ type Strategy struct { | |||
| 69 | 65 | // - more than 256 jobs is an error. A strategy without a matrix gives a | |
| 70 | 66 | // single combination. | |
| 71 | 67 | // | |
| 68 | + | // The work is bounded: the product of the vectors may not exceed | |
| 69 | + | // [MaxMatrixProduct], include and exclude may have [MaxMatrixEntries] entries | |
| 70 | + | // each, and ctx is checked every few thousand combinations. | |
| 71 | + | // | |
| 72 | 72 | // TODO(verify with recording): the documented semantics of include and | |
| 73 | 73 | // exclude are implemented as documented, but the following are assumed: the | |
| 74 | 74 | // order of the cross product (first key slowest), the display name listing | |
| 75 | 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) | |
| 76 | + | // [MatrixCombo.DisplayName]) and showing null as an empty segment, the | |
| 77 | + | // comparison of values (same kind, strings compared exactly, mappings by | |
| 78 | + | // containment), an include key matching a vector key case-insensitively, the | |
| 79 | + | // error when every combination is excluded, the limits above, and the | |
| 80 | + | // wording of all errors except the unknown exclude key, which is GitHub's | |
| 81 | + | // (also to verify). | |
| 82 | + | func ExpandMatrix(ctx context.Context, strategy expr.Value) ([]MatrixCombo, error) { | |
| 83 | + | s, err := ParseStrategy(ctx, strategy) | |
| 82 | 84 | if err != nil { | |
| 83 | 85 | return nil, err | |
| 84 | 86 | } | |
| 85 | 87 | return s.Combos, nil | |
| 86 | 88 | } | |
| 87 | 89 | ||
| 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) { | |
| 90 | + | func buildMatrix(ctx context.Context, m expr.Value) ([]MatrixCombo, bool, error) { | |
| 184 | 91 | switch m.(type) { | |
| 185 | 92 | case nil, expr.Null: | |
| 186 | 93 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | |
| @@ -192,8 +99,7 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||
| 192 | 99 | if md == nil { | |
| 193 | 100 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | |
| 194 | 101 | } | |
| 195 | - | var keys []string | |
| 196 | - | vectors := map[string][]expr.Value{} | |
| 102 | + | var vecs []vector | |
| 197 | 103 | var includes, excludes []*expr.Dictionary | |
| 198 | 104 | for _, p := range md.Pairs() { | |
| 199 | 105 | switch p.Key { | |
| @@ -211,17 +117,16 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||
| 211 | 117 | excludes = list | |
| 212 | 118 | default: | |
| 213 | 119 | arr, ok := p.Value.(*expr.Array) | |
| 214 | - | if !ok || arr == nil { | |
| 120 | + | if !ok || arr == nil || arr.Len() == 0 { | |
| 215 | 121 | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | |
| 216 | 122 | } | |
| 217 | - | if arr.Len() == 0 { | |
| 218 | - | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | |
| 123 | + | if arr.Len() > MaxMatrixProduct { | |
| 124 | + | return nil, false, errTooBig | |
| 219 | 125 | } | |
| 220 | - | keys = append(keys, p.Key) | |
| 221 | - | vectors[p.Key] = arr.Items() | |
| 126 | + | vecs = append(vecs, vector{key: p.Key, items: arr.Items()}) | |
| 222 | 127 | } | |
| 223 | 128 | } | |
| 224 | - | return expand(keys, vectors, includes, excludes) | |
| 129 | + | return expand(ctx, vecs, includes, excludes) | |
| 225 | 130 | } | |
| 226 | 131 | ||
| 227 | 132 | func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | |
| @@ -229,6 +134,9 @@ func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | |||
| 229 | 134 | if !ok || arr == nil { | |
| 230 | 135 | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) | |
| 231 | 136 | } | |
| 137 | + | if arr.Len() > MaxMatrixEntries { | |
| 138 | + | return nil, fmt.Errorf("matrix %s has more than %d entries", name, MaxMatrixEntries) | |
| 139 | + | } | |
| 232 | 140 | var out []*expr.Dictionary | |
| 233 | 141 | for _, it := range arr.Items() { | |
| 234 | 142 | d, ok := it.(*expr.Dictionary) | |
| @@ -240,72 +148,119 @@ func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | |||
| 240 | 148 | return out, nil | |
| 241 | 149 | } | |
| 242 | 150 | ||
| 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 | |
| 151 | + | // message is an error whose text is GitHub's, which starts with a capital. | |
| 152 | + | type message string | |
| 153 | + | ||
| 154 | + | func (m message) Error() string { return string(m) } | |
| 155 | + | ||
| 156 | + | var errTooBig = fmt.Errorf("matrix size limit of %d exceeded", MaxMatrixJobs) | |
| 157 | + | ||
| 158 | + | type vector struct { | |
| 159 | + | key string | |
| 160 | + | items []expr.Value | |
| 249 | 161 | } | |
| 250 | 162 | ||
| 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) | |
| 163 | + | // condition says which items of vector key may appear in a combination that | |
| 164 | + | // an exclude or include entry applies to. | |
| 165 | + | type condition struct { | |
| 166 | + | vec int | |
| 167 | + | ok []bool // by index of the item in the vector | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | type includeEntry struct { | |
| 171 | + | conds []condition | |
| 172 | + | extra []expr.Pair // pairs whose key is not a vector | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func compileCondition(vecs []vector, vi int, want expr.Value) condition { | |
| 176 | + | c := condition{vec: vi, ok: make([]bool, len(vecs[vi].items))} | |
| 177 | + | for i, it := range vecs[vi].items { | |
| 178 | + | c.ok[i] = valuesMatch(it, want) | |
| 255 | 179 | } | |
| 256 | - | b.vals[lk] = v | |
| 180 | + | return c | |
| 257 | 181 | } | |
| 258 | 182 | ||
| 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) | |
| 183 | + | func expand(ctx context.Context, vecs []vector, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { | |
| 184 | + | index := make(map[string]int, len(vecs)) | |
| 185 | + | for i, v := range vecs { | |
| 186 | + | index[strings.ToLower(v.key)] = i | |
| 187 | + | } | |
| 188 | + | // Exclude entries become lists of conditions on item positions, so that | |
| 189 | + | // testing a combination costs a few array reads. | |
| 190 | + | var excl [][]condition | |
| 191 | + | for _, ex := range excludes { | |
| 192 | + | var conds []condition | |
| 193 | + | for _, p := range ex.Pairs() { | |
| 194 | + | vi, ok := index[strings.ToLower(p.Key)] | |
| 195 | + | if !ok { | |
| 196 | + | return nil, false, message(fmt.Sprintf("Matrix exclude key '%s' does not match any key within the matrix", p.Key)) | |
| 197 | + | } | |
| 198 | + | conds = append(conds, compileCondition(vecs, vi, p.Value)) | |
| 199 | + | } | |
| 200 | + | if len(conds) > 0 { | |
| 201 | + | excl = append(excl, conds) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | incl := make([]includeEntry, len(includes)) | |
| 205 | + | for n, inc := range includes { | |
| 206 | + | for _, p := range inc.Pairs() { | |
| 207 | + | if vi, ok := index[strings.ToLower(p.Key)]; ok { | |
| 208 | + | incl[n].conds = append(incl[n].conds, compileCondition(vecs, vi, p.Value)) | |
| 209 | + | } else { | |
| 210 | + | incl[n].extra = append(incl[n].extra, p) | |
| 211 | + | } | |
| 267 | 212 | } | |
| 268 | 213 | } | |
| 269 | - | return MatrixCombo{Values: d, Segments: segs} | |
| 270 | - | } | |
| 271 | 214 | ||
| 272 | - | func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { | |
| 273 | 215 | var combos []MatrixCombo | |
| 274 | 216 | matched := make([]bool, len(includes)) | |
| 275 | - | tooBig := fmt.Errorf("matrix size limit of %d exceeded", MaxMatrixJobs) | |
| 276 | - | if len(keys) > 0 { | |
| 217 | + | if len(vecs) > 0 { | |
| 277 | 218 | product := 1 | |
| 278 | - | for _, k := range keys { | |
| 279 | - | product *= len(vectors[k]) | |
| 280 | - | if product > maxMatrixProduct { | |
| 281 | - | return nil, false, tooBig | |
| 219 | + | for _, v := range vecs { | |
| 220 | + | product *= len(v.items) | |
| 221 | + | if product > MaxMatrixProduct { | |
| 222 | + | return nil, false, errTooBig | |
| 282 | 223 | } | |
| 283 | 224 | } | |
| 225 | + | tuple := make([]int, len(vecs)) | |
| 226 | + | next: | |
| 284 | 227 | 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])]) | |
| 228 | + | if i%checkEvery == 0 { | |
| 229 | + | if err := ctx.Err(); err != nil { | |
| 230 | + | return nil, false, err | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | if i > 0 { | |
| 234 | + | // Advance the last position first: the first key varies slowest. | |
| 235 | + | for k := len(tuple) - 1; k >= 0; k-- { | |
| 236 | + | tuple[k]++ | |
| 237 | + | if tuple[k] < len(vecs[k].items) { | |
| 238 | + | break | |
| 239 | + | } | |
| 240 | + | tuple[k] = 0 | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | for _, conds := range excl { | |
| 244 | + | if allHold(conds, tuple) { | |
| 245 | + | continue next | |
| 246 | + | } | |
| 290 | 247 | } | |
| 291 | - | base := b.combo().Values | |
| 292 | - | if excluded(base, excludes) { | |
| 293 | - | continue | |
| 248 | + | b := &comboBuilder{vals: map[string]expr.Value{}} | |
| 249 | + | for k, v := range vecs { | |
| 250 | + | b.set(v.key, v.items[tuple[k]]) | |
| 294 | 251 | } | |
| 295 | - | for n, inc := range includes { | |
| 296 | - | if !includeMatches(base, keys, inc) { | |
| 252 | + | for n, inc := range incl { | |
| 253 | + | if !allHold(inc.conds, tuple) { | |
| 297 | 254 | continue | |
| 298 | 255 | } | |
| 299 | 256 | 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 | - | } | |
| 257 | + | for _, p := range inc.extra { | |
| 258 | + | b.set(p.Key, p.Value) | |
| 304 | 259 | } | |
| 305 | 260 | } | |
| 306 | 261 | combos = append(combos, b.combo()) | |
| 307 | 262 | if len(combos) > MaxMatrixJobs { | |
| 308 | - | return nil, false, tooBig | |
| 263 | + | return nil, false, errTooBig | |
| 309 | 264 | } | |
| 310 | 265 | } | |
| 311 | 266 | } | |
| @@ -319,11 +274,11 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [ | |||
| 319 | 274 | } | |
| 320 | 275 | combos = append(combos, b.combo()) | |
| 321 | 276 | if len(combos) > MaxMatrixJobs { | |
| 322 | - | return nil, false, tooBig | |
| 277 | + | return nil, false, errTooBig | |
| 323 | 278 | } | |
| 324 | 279 | } | |
| 325 | 280 | if len(combos) == 0 { | |
| 326 | - | if len(keys) == 0 && len(includes) == 0 && len(excludes) == 0 { | |
| 281 | + | if len(vecs) == 0 && len(includes) == 0 && len(excludes) == 0 { | |
| 327 | 282 | // An empty matrix runs the job once. | |
| 328 | 283 | return []MatrixCombo{{Values: expr.NewDictionary()}}, true, nil | |
| 329 | 284 | } | |
| @@ -334,50 +289,42 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [ | |||
| 334 | 289 | return combos, true, nil | |
| 335 | 290 | } | |
| 336 | 291 | ||
| 337 | - | func hasKeyFold(keys []string, key string) bool { | |
| 338 | - | for _, k := range keys { | |
| 339 | - | if strings.EqualFold(k, key) { | |
| 340 | - | return true | |
| 292 | + | func allHold(conds []condition, tuple []int) bool { | |
| 293 | + | for _, c := range conds { | |
| 294 | + | if !c.ok[tuple[c.vec]] { | |
| 295 | + | return false | |
| 341 | 296 | } | |
| 342 | 297 | } | |
| 343 | - | return false | |
| 298 | + | return true | |
| 344 | 299 | } | |
| 345 | 300 | ||
| 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 | - | } | |
| 301 | + | // comboBuilder holds the values of one combination while includes are | |
| 302 | + | // applied; added values may be overwritten by later includes, original ones | |
| 303 | + | // never are (an include's vector keys are conditions, not assignments). | |
| 304 | + | type comboBuilder struct { | |
| 305 | + | keys []string | |
| 306 | + | vals map[string]expr.Value // by lower-cased key | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (b *comboBuilder) set(key string, v expr.Value) { | |
| 310 | + | lk := strings.ToLower(key) | |
| 311 | + | if _, ok := b.vals[lk]; !ok { | |
| 312 | + | b.keys = append(b.keys, key) | |
| 363 | 313 | } | |
| 364 | - | return false | |
| 314 | + | b.vals[lk] = v | |
| 365 | 315 | } | |
| 366 | 316 | ||
| 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 | |
| 317 | + | func (b *comboBuilder) combo() MatrixCombo { | |
| 318 | + | d := expr.NewDictionary() | |
| 319 | + | var segs []string | |
| 320 | + | for _, k := range b.keys { | |
| 321 | + | v := b.vals[strings.ToLower(k)] | |
| 322 | + | d.Add(k, v) | |
| 323 | + | if s, ok := segment(v); ok { | |
| 324 | + | segs = append(segs, s) | |
| 378 | 325 | } | |
| 379 | 326 | } | |
| 380 | - | return true | |
| 327 | + | return MatrixCombo{Values: d, Segments: segs} | |
| 381 | 328 | } | |
| 382 | 329 | ||
| 383 | 330 | // valuesMatch compares a matrix value with the value in an include or | |
| @@ -3,21 +3,29 @@ | |||||
| 3 | package workflow | 3 | package workflow | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | - | "strconv" | |||
| 9 | "strings" | 9 | "strings" | ||
| 10 | 10 | ||||
| 11 | "bnema.dev/ermite-actions/expr" | 11 | "bnema.dev/ermite-actions/expr" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | - | // MaxMatrixJobs is the number of jobs a matrix may produce. | 14 | + | // Limits of matrix expansion. GitHub only limits the number of jobs; the |
| 15 | - | const MaxMatrixJobs = 256 | 15 | + | // other limits keep the work done for a hostile matrix bounded. |
| 16 | + | const ( | |||
| 17 | + | // MaxMatrixJobs is the number of jobs a matrix may produce. | |||
| 18 | + | MaxMatrixJobs = 256 | |||
| 19 | + | // MaxMatrixProduct is the size of the cross product of the vectors, before | |||
| 20 | + | // exclusions, above which a matrix is refused. | |||
| 21 | + | MaxMatrixProduct = 1 << 16 | |||
| 22 | + | // MaxMatrixEntries is the number of entries of include and, separately, | |||
| 23 | + | // of exclude. | |||
| 24 | + | MaxMatrixEntries = 256 | |||
| 25 | + | ) | |||
| 16 | 26 | ||||
| 17 | - | // maxMatrixProduct bounds the size of the cross product before exclusions, | 27 | + | // checkEvery is the number of combinations between two checks of the context. |
| 18 | - | // so that a matrix of many large vectors fails fast instead of looping. | 28 | + | const checkEvery = 1024 |
| 19 | - | // GitHub only limits the final number of jobs. | |||
| 20 | - | const maxMatrixProduct = 1 << 20 | |||
| 21 | 29 | ||||
| 22 | // MatrixCombo is one job of a matrix. | 30 | // MatrixCombo is one job of a matrix. | ||
| 23 | type MatrixCombo struct { | 31 | type MatrixCombo struct { | ||
| @@ -27,7 +35,7 @@ type MatrixCombo struct { | |||||
| 27 | Values *expr.Dictionary | 35 | Values *expr.Dictionary | ||
| 28 | // Segments are the scalar values of Values, in order, shown in the name | 36 | // 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 | 37 | // of the job: "build (ubuntu-latest, 14)". Arrays and mappings are left | ||
| 30 | - | // out. | 38 | + | // out; null shows as an empty segment. |
| 31 | Segments []string | 39 | Segments []string | ||
| 32 | } | 40 | } | ||
| 33 | 41 | ||||
| @@ -40,19 +48,6 @@ func (c MatrixCombo) DisplayName(base string) string { | |||||
| 40 | return base + " (" + strings.Join(c.Segments, ", ") + ")" | 48 | return base + " (" + strings.Join(c.Segments, ", ") + ")" | ||
| 41 | } | 49 | } | ||
| 42 | 50 | ||||
| 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 | 51 | // ExpandMatrix expands the matrix of an evaluated strategy (a dictionary with | ||
| 57 | // the optional keys "matrix", "fail-fast" and "max-parallel", or null) into | 52 | // the optional keys "matrix", "fail-fast" and "max-parallel", or null) into | ||
| 58 | // the job combinations, like GitHub: | 53 | // the job combinations, like GitHub: | ||
| @@ -60,7 +55,8 @@ type Strategy struct { | |||||
| 60 | // - the combinations are the cross product of the vectors, the first key | 55 | // - the combinations are the cross product of the vectors, the first key | ||
| 61 | // varying slowest; | 56 | // varying slowest; | ||
| 62 | // - exclude removes every combination that contains all the key-value | 57 | // - exclude removes every combination that contains all the key-value | ||
| 63 | - | // pairs of one of its entries; | 58 | + | // pairs of one of its entries; every key of an entry must be a vector of |
| 59 | + | // the matrix, or the matrix is an error; | |||
| 64 | // - then each include entry is added to every remaining combination whose | 60 | // - then each include entry is added to every remaining combination whose | ||
| 65 | // values for the vector keys it mentions are equal (an entry that | 61 | // values for the vector keys it mentions are equal (an entry that | ||
| 66 | // mentions no vector key matches all), without overwriting the original | 62 | // mentions no vector key matches all), without overwriting the original | ||
| @@ -69,118 +65,29 @@ type Strategy struct { | |||||
| 69 | // - more than 256 jobs is an error. A strategy without a matrix gives a | 65 | // - more than 256 jobs is an error. A strategy without a matrix gives a | ||
| 70 | // single combination. | 66 | // single combination. | ||
| 71 | // | 67 | // | ||
| 68 | + | // The work is bounded: the product of the vectors may not exceed | |||
| 69 | + | // [MaxMatrixProduct], include and exclude may have [MaxMatrixEntries] entries | |||
| 70 | + | // each, and ctx is checked every few thousand combinations. | |||
| 71 | + | // | |||
| 72 | // TODO(verify with recording): the documented semantics of include and | 72 | // TODO(verify with recording): the documented semantics of include and | ||
| 73 | // exclude are implemented as documented, but the following are assumed: the | 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 | 74 | // order of the cross product (first key slowest), the display name listing | ||
| 75 | // every scalar value including those added by include (see | 75 | // every scalar value including those added by include (see | ||
| 76 | - | // [MatrixCombo.DisplayName]), the comparison of values (same kind, strings | 76 | + | // [MatrixCombo.DisplayName]) and showing null as an empty segment, the |
| 77 | - | // compared exactly, mappings by containment), an include key matching a | 77 | + | // comparison of values (same kind, strings compared exactly, mappings by |
| 78 | - | // vector key case-insensitively, the error when every combination is | 78 | + | // containment), an include key matching a vector key case-insensitively, the |
| 79 | - | // excluded, and the wording of all errors. | 79 | + | // error when every combination is excluded, the limits above, and the |
| 80 | - | func ExpandMatrix(strategy expr.Value) ([]MatrixCombo, error) { | 80 | + | // wording of all errors except the unknown exclude key, which is GitHub's |
| 81 | - | s, err := ParseStrategy(strategy) | 81 | + | // (also to verify). |
| 82 | + | func ExpandMatrix(ctx context.Context, strategy expr.Value) ([]MatrixCombo, error) { | |||
| 83 | + | s, err := ParseStrategy(ctx, strategy) | |||
| 82 | if err != nil { | 84 | if err != nil { | ||
| 83 | return nil, err | 85 | return nil, err | ||
| 84 | } | 86 | } | ||
| 85 | return s.Combos, nil | 87 | return s.Combos, nil | ||
| 86 | } | 88 | } | ||
| 87 | 89 | ||||
| 88 | - | // ParseStrategy reads an evaluated strategy value and expands its matrix. | 90 | + | func buildMatrix(ctx context.Context, m expr.Value) ([]MatrixCombo, bool, error) { |
| 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) { | 91 | switch m.(type) { | ||
| 185 | case nil, expr.Null: | 92 | case nil, expr.Null: | ||
| 186 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | 93 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | ||
| @@ -192,8 +99,7 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||||
| 192 | if md == nil { | 99 | if md == nil { | ||
| 193 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | 100 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | ||
| 194 | } | 101 | } | ||
| 195 | - | var keys []string | 102 | + | var vecs []vector |
| 196 | - | vectors := map[string][]expr.Value{} | |||
| 197 | var includes, excludes []*expr.Dictionary | 103 | var includes, excludes []*expr.Dictionary | ||
| 198 | for _, p := range md.Pairs() { | 104 | for _, p := range md.Pairs() { | ||
| 199 | switch p.Key { | 105 | switch p.Key { | ||
| @@ -211,17 +117,16 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||||
| 211 | excludes = list | 117 | excludes = list | ||
| 212 | default: | 118 | default: | ||
| 213 | arr, ok := p.Value.(*expr.Array) | 119 | arr, ok := p.Value.(*expr.Array) | ||
| 214 | - | if !ok || arr == nil { | 120 | + | if !ok || arr == nil || arr.Len() == 0 { |
| 215 | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | 121 | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | ||
| 216 | } | 122 | } | ||
| 217 | - | if arr.Len() == 0 { | 123 | + | if arr.Len() > MaxMatrixProduct { |
| 218 | - | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | 124 | + | return nil, false, errTooBig |
| 219 | } | 125 | } | ||
| 220 | - | keys = append(keys, p.Key) | 126 | + | vecs = append(vecs, vector{key: p.Key, items: arr.Items()}) |
| 221 | - | vectors[p.Key] = arr.Items() | |||
| 222 | } | 127 | } | ||
| 223 | } | 128 | } | ||
| 224 | - | return expand(keys, vectors, includes, excludes) | 129 | + | return expand(ctx, vecs, includes, excludes) |
| 225 | } | 130 | } | ||
| 226 | 131 | ||||
| 227 | func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | 132 | func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | ||
| @@ -229,6 +134,9 @@ func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | |||||
| 229 | if !ok || arr == nil { | 134 | if !ok || arr == nil { | ||
| 230 | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) | 135 | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) | ||
| 231 | } | 136 | } | ||
| 137 | + | if arr.Len() > MaxMatrixEntries { | |||
| 138 | + | return nil, fmt.Errorf("matrix %s has more than %d entries", name, MaxMatrixEntries) | |||
| 139 | + | } | |||
| 232 | var out []*expr.Dictionary | 140 | var out []*expr.Dictionary | ||
| 233 | for _, it := range arr.Items() { | 141 | for _, it := range arr.Items() { | ||
| 234 | d, ok := it.(*expr.Dictionary) | 142 | d, ok := it.(*expr.Dictionary) | ||
| @@ -240,72 +148,119 @@ func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | |||||
| 240 | return out, nil | 148 | return out, nil | ||
| 241 | } | 149 | } | ||
| 242 | 150 | ||||
| 243 | - | // comboBuilder holds the values of one combination while includes are | 151 | + | // message is an error whose text is GitHub's, which starts with a capital. |
| 244 | - | // applied; added values may be overwritten by later includes, original ones | 152 | + | type message string |
| 245 | - | // never are. | 153 | + | |
| 246 | - | type comboBuilder struct { | 154 | + | func (m message) Error() string { return string(m) } |
| 247 | - | keys []string | 155 | + | |
| 248 | - | vals map[string]expr.Value // by lower-cased key | 156 | + | var errTooBig = fmt.Errorf("matrix size limit of %d exceeded", MaxMatrixJobs) |
| 157 | + | ||||
| 158 | + | type vector struct { | |||
| 159 | + | key string | |||
| 160 | + | items []expr.Value | |||
| 249 | } | 161 | } | ||
| 250 | 162 | ||||
| 251 | - | func (b *comboBuilder) set(key string, v expr.Value) { | 163 | + | // condition says which items of vector key may appear in a combination that |
| 252 | - | lk := strings.ToLower(key) | 164 | + | // an exclude or include entry applies to. |
| 253 | - | if _, ok := b.vals[lk]; !ok { | 165 | + | type condition struct { |
| 254 | - | b.keys = append(b.keys, key) | 166 | + | vec int |
| 167 | + | ok []bool // by index of the item in the vector | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | type includeEntry struct { | |||
| 171 | + | conds []condition | |||
| 172 | + | extra []expr.Pair // pairs whose key is not a vector | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func compileCondition(vecs []vector, vi int, want expr.Value) condition { | |||
| 176 | + | c := condition{vec: vi, ok: make([]bool, len(vecs[vi].items))} | |||
| 177 | + | for i, it := range vecs[vi].items { | |||
| 178 | + | c.ok[i] = valuesMatch(it, want) | |||
| 255 | } | 179 | } | ||
| 256 | - | b.vals[lk] = v | 180 | + | return c |
| 257 | } | 181 | } | ||
| 258 | 182 | ||||
| 259 | - | func (b *comboBuilder) combo() MatrixCombo { | 183 | + | func expand(ctx context.Context, vecs []vector, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { |
| 260 | - | d := expr.NewDictionary() | 184 | + | index := make(map[string]int, len(vecs)) |
| 261 | - | var segs []string | 185 | + | for i, v := range vecs { |
| 262 | - | for _, k := range b.keys { | 186 | + | index[strings.ToLower(v.key)] = i |
| 263 | - | v := b.vals[strings.ToLower(k)] | 187 | + | } |
| 264 | - | d.Add(k, v) | 188 | + | // Exclude entries become lists of conditions on item positions, so that |
| 265 | - | if s, ok := segment(v); ok { | 189 | + | // testing a combination costs a few array reads. |
| 266 | - | segs = append(segs, s) | 190 | + | var excl [][]condition |
| 191 | + | for _, ex := range excludes { | |||
| 192 | + | var conds []condition | |||
| 193 | + | for _, p := range ex.Pairs() { | |||
| 194 | + | vi, ok := index[strings.ToLower(p.Key)] | |||
| 195 | + | if !ok { | |||
| 196 | + | return nil, false, message(fmt.Sprintf("Matrix exclude key '%s' does not match any key within the matrix", p.Key)) | |||
| 197 | + | } | |||
| 198 | + | conds = append(conds, compileCondition(vecs, vi, p.Value)) | |||
| 199 | + | } | |||
| 200 | + | if len(conds) > 0 { | |||
| 201 | + | excl = append(excl, conds) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | incl := make([]includeEntry, len(includes)) | |||
| 205 | + | for n, inc := range includes { | |||
| 206 | + | for _, p := range inc.Pairs() { | |||
| 207 | + | if vi, ok := index[strings.ToLower(p.Key)]; ok { | |||
| 208 | + | incl[n].conds = append(incl[n].conds, compileCondition(vecs, vi, p.Value)) | |||
| 209 | + | } else { | |||
| 210 | + | incl[n].extra = append(incl[n].extra, p) | |||
| 211 | + | } | |||
| 267 | } | 212 | } | ||
| 268 | } | 213 | } | ||
| 269 | - | return MatrixCombo{Values: d, Segments: segs} | |||
| 270 | - | } | |||
| 271 | 214 | ||||
| 272 | - | func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { | |||
| 273 | var combos []MatrixCombo | 215 | var combos []MatrixCombo | ||
| 274 | matched := make([]bool, len(includes)) | 216 | matched := make([]bool, len(includes)) | ||
| 275 | - | tooBig := fmt.Errorf("matrix size limit of %d exceeded", MaxMatrixJobs) | 217 | + | if len(vecs) > 0 { |
| 276 | - | if len(keys) > 0 { | |||
| 277 | product := 1 | 218 | product := 1 | ||
| 278 | - | for _, k := range keys { | 219 | + | for _, v := range vecs { |
| 279 | - | product *= len(vectors[k]) | 220 | + | product *= len(v.items) |
| 280 | - | if product > maxMatrixProduct { | 221 | + | if product > MaxMatrixProduct { |
| 281 | - | return nil, false, tooBig | 222 | + | return nil, false, errTooBig |
| 282 | } | 223 | } | ||
| 283 | } | 224 | } | ||
| 225 | + | tuple := make([]int, len(vecs)) | |||
| 226 | + | next: | |||
| 284 | for i := 0; i < product; i++ { | 227 | for i := 0; i < product; i++ { | ||
| 285 | - | b := &comboBuilder{vals: map[string]expr.Value{}} | 228 | + | if i%checkEvery == 0 { |
| 286 | - | div := product | 229 | + | if err := ctx.Err(); err != nil { |
| 287 | - | for _, k := range keys { | 230 | + | return nil, false, err |
| 288 | - | div /= len(vectors[k]) | 231 | + | } |
| 289 | - | b.set(k, vectors[k][(i/div)%len(vectors[k])]) | 232 | + | } |
| 233 | + | if i > 0 { | |||
| 234 | + | // Advance the last position first: the first key varies slowest. | |||
| 235 | + | for k := len(tuple) - 1; k >= 0; k-- { | |||
| 236 | + | tuple[k]++ | |||
| 237 | + | if tuple[k] < len(vecs[k].items) { | |||
| 238 | + | break | |||
| 239 | + | } | |||
| 240 | + | tuple[k] = 0 | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | for _, conds := range excl { | |||
| 244 | + | if allHold(conds, tuple) { | |||
| 245 | + | continue next | |||
| 246 | + | } | |||
| 290 | } | 247 | } | ||
| 291 | - | base := b.combo().Values | 248 | + | b := &comboBuilder{vals: map[string]expr.Value{}} |
| 292 | - | if excluded(base, excludes) { | 249 | + | for k, v := range vecs { |
| 293 | - | continue | 250 | + | b.set(v.key, v.items[tuple[k]]) |
| 294 | } | 251 | } | ||
| 295 | - | for n, inc := range includes { | 252 | + | for n, inc := range incl { |
| 296 | - | if !includeMatches(base, keys, inc) { | 253 | + | if !allHold(inc.conds, tuple) { |
| 297 | continue | 254 | continue | ||
| 298 | } | 255 | } | ||
| 299 | matched[n] = true | 256 | matched[n] = true | ||
| 300 | - | for _, p := range inc.Pairs() { | 257 | + | for _, p := range inc.extra { |
| 301 | - | if _, isVector := vectors[p.Key]; !isVector && !hasKeyFold(keys, p.Key) { | 258 | + | b.set(p.Key, p.Value) |
| 302 | - | b.set(p.Key, p.Value) | |||
| 303 | - | } | |||
| 304 | } | 259 | } | ||
| 305 | } | 260 | } | ||
| 306 | combos = append(combos, b.combo()) | 261 | combos = append(combos, b.combo()) | ||
| 307 | if len(combos) > MaxMatrixJobs { | 262 | if len(combos) > MaxMatrixJobs { | ||
| 308 | - | return nil, false, tooBig | 263 | + | return nil, false, errTooBig |
| 309 | } | 264 | } | ||
| 310 | } | 265 | } | ||
| 311 | } | 266 | } | ||
| @@ -319,11 +274,11 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [ | |||||
| 319 | } | 274 | } | ||
| 320 | combos = append(combos, b.combo()) | 275 | combos = append(combos, b.combo()) | ||
| 321 | if len(combos) > MaxMatrixJobs { | 276 | if len(combos) > MaxMatrixJobs { | ||
| 322 | - | return nil, false, tooBig | 277 | + | return nil, false, errTooBig |
| 323 | } | 278 | } | ||
| 324 | } | 279 | } | ||
| 325 | if len(combos) == 0 { | 280 | if len(combos) == 0 { | ||
| 326 | - | if len(keys) == 0 && len(includes) == 0 && len(excludes) == 0 { | 281 | + | if len(vecs) == 0 && len(includes) == 0 && len(excludes) == 0 { |
| 327 | // An empty matrix runs the job once. | 282 | // An empty matrix runs the job once. | ||
| 328 | return []MatrixCombo{{Values: expr.NewDictionary()}}, true, nil | 283 | return []MatrixCombo{{Values: expr.NewDictionary()}}, true, nil | ||
| 329 | } | 284 | } | ||
| @@ -334,50 +289,42 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [ | |||||
| 334 | return combos, true, nil | 289 | return combos, true, nil | ||
| 335 | } | 290 | } | ||
| 336 | 291 | ||||
| 337 | - | func hasKeyFold(keys []string, key string) bool { | 292 | + | func allHold(conds []condition, tuple []int) bool { |
| 338 | - | for _, k := range keys { | 293 | + | for _, c := range conds { |
| 339 | - | if strings.EqualFold(k, key) { | 294 | + | if !c.ok[tuple[c.vec]] { |
| 340 | - | return true | 295 | + | return false |
| 341 | } | 296 | } | ||
| 342 | } | 297 | } | ||
| 343 | - | return false | 298 | + | return true |
| 344 | } | 299 | } | ||
| 345 | 300 | ||||
| 346 | - | // excluded reports whether any exclude entry is contained in vals. | 301 | + | // comboBuilder holds the values of one combination while includes are |
| 347 | - | func excluded(vals *expr.Dictionary, excludes []*expr.Dictionary) bool { | 302 | + | // applied; added values may be overwritten by later includes, original ones |
| 348 | - | for _, ex := range excludes { | 303 | + | // never are (an include's vector keys are conditions, not assignments). |
| 349 | - | if ex.Len() == 0 { | 304 | + | type comboBuilder struct { |
| 350 | - | continue | 305 | + | keys []string |
| 351 | - | } | 306 | + | vals map[string]expr.Value // by lower-cased key |
| 352 | - | all := true | 307 | + | } |
| 353 | - | for _, p := range ex.Pairs() { | 308 | + | |
| 354 | - | have, ok := vals.Get(p.Key) | 309 | + | func (b *comboBuilder) set(key string, v expr.Value) { |
| 355 | - | if !ok || !valuesMatch(have, p.Value) { | 310 | + | lk := strings.ToLower(key) |
| 356 | - | all = false | 311 | + | if _, ok := b.vals[lk]; !ok { |
| 357 | - | break | 312 | + | b.keys = append(b.keys, key) |
| 358 | - | } | |||
| 359 | - | } | |||
| 360 | - | if all { | |||
| 361 | - | return true | |||
| 362 | - | } | |||
| 363 | } | 313 | } | ||
| 364 | - | return false | 314 | + | b.vals[lk] = v |
| 365 | } | 315 | } | ||
| 366 | 316 | ||||
| 367 | - | // includeMatches reports whether an include entry applies to vals: every key | 317 | + | func (b *comboBuilder) combo() MatrixCombo { |
| 368 | - | // of the entry that is a vector of the matrix has the same value. An entry | 318 | + | d := expr.NewDictionary() |
| 369 | - | // with no such key applies to all combinations. | 319 | + | var segs []string |
| 370 | - | func includeMatches(vals *expr.Dictionary, vectorKeys []string, inc *expr.Dictionary) bool { | 320 | + | for _, k := range b.keys { |
| 371 | - | for _, p := range inc.Pairs() { | 321 | + | v := b.vals[strings.ToLower(k)] |
| 372 | - | if !hasKeyFold(vectorKeys, p.Key) { | 322 | + | d.Add(k, v) |
| 373 | - | continue | 323 | + | if s, ok := segment(v); ok { |
| 374 | - | } | 324 | + | segs = append(segs, s) |
| 375 | - | have, _ := vals.Get(p.Key) | |||
| 376 | - | if !valuesMatch(have, p.Value) { | |||
| 377 | - | return false | |||
| 378 | } | 325 | } | ||
| 379 | } | 326 | } | ||
| 380 | - | return true | 327 | + | return MatrixCombo{Values: d, Segments: segs} |
| 381 | } | 328 | } | ||
| 382 | 329 | ||||
| 383 | // valuesMatch compares a matrix value with the value in an include or | 330 | // valuesMatch compares a matrix value with the value in an include or | ||
M workflow/matrix_test.go +135 -10
| @@ -3,14 +3,20 @@ | |||
| 3 | 3 | package workflow | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 6 | 8 | "fmt" | |
| 7 | 9 | "reflect" | |
| 8 | 10 | "strings" | |
| 9 | 11 | "testing" | |
| 12 | + | "time" | |
| 10 | 13 | ||
| 11 | 14 | "bnema.dev/ermite-actions/expr" | |
| 15 | + | "bnema.dev/ermite-actions/template" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| 18 | + | var ctxbg = context.Background() | |
| 19 | + | ||
| 14 | 20 | func jsonValue(t testing.TB, s string) expr.Value { | |
| 15 | 21 | t.Helper() | |
| 16 | 22 | v, err := expr.FromJSON([]byte(s)) | |
| @@ -128,7 +134,7 @@ func TestExpandMatrix(t *testing.T) { | |||
| 128 | 134 | }, | |
| 129 | 135 | { | |
| 130 | 136 | "exclude no match", | |
| 131 | - | `{"matrix": {"a": [1], "exclude": [{"a": 2}, {"zzz": 1}]}}`, | |
| 137 | + | `{"matrix": {"a": [1], "exclude": [{"a": 2}]}}`, | |
| 132 | 138 | []string{`a=1 | job (1)`}, | |
| 133 | 139 | }, | |
| 134 | 140 | { | |
| @@ -154,7 +160,7 @@ func TestExpandMatrix(t *testing.T) { | |||
| 154 | 160 | } | |
| 155 | 161 | for _, tc := range tests { | |
| 156 | 162 | t.Run(tc.name, func(t *testing.T) { | |
| 157 | - | combos, err := ExpandMatrix(jsonValue(t, tc.strategy)) | |
| 163 | + | combos, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy)) | |
| 158 | 164 | if err != nil { | |
| 159 | 165 | t.Fatal(err) | |
| 160 | 166 | } | |
| @@ -174,6 +180,11 @@ func TestExpandMatrixErrors(t *testing.T) { | |||
| 174 | 180 | {"include not a list", `{"matrix": {"include": 1}}`, "matrix include must be a sequence of mappings"}, | |
| 175 | 181 | {"include item", `{"matrix": {"include": [1]}}`, "matrix include must be a sequence of mappings"}, | |
| 176 | 182 | {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "matrix exclude must be a sequence of mappings"}, | |
| 183 | + | {"exclude unknown key", `{"matrix": {"a": [1], "exclude": [{"zzz": 1}]}}`, "Matrix exclude key 'zzz' does not match any key within the matrix"}, | |
| 184 | + | {"exclude include-only key", `{"matrix": {"a": [1], "include": [{"b": 1}], "exclude": [{"b": 1}]}}`, "Matrix exclude key 'b' does not match any key within the matrix"}, | |
| 185 | + | {"exclude without vectors", `{"matrix": {"exclude": [{"a": 1}]}}`, "Matrix exclude key 'a' does not match any key within the matrix"}, | |
| 186 | + | {"too many includes", `{"matrix": {"include": ` + entries(257) + `}}`, "more than 256 entries"}, | |
| 187 | + | {"too many excludes", `{"matrix": {"a": [1], "exclude": ` + entries(257) + `}}`, "more than 256 entries"}, | |
| 177 | 188 | {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"}, | |
| 178 | 189 | {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"}, | |
| 179 | 190 | {"max-parallel", `{"max-parallel": 0}`, "max-parallel"}, | |
| @@ -181,7 +192,7 @@ func TestExpandMatrixErrors(t *testing.T) { | |||
| 181 | 192 | } | |
| 182 | 193 | for _, tc := range tests { | |
| 183 | 194 | t.Run(tc.name, func(t *testing.T) { | |
| 184 | - | _, err := ExpandMatrix(jsonValue(t, tc.strategy)) | |
| 195 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy)) | |
| 185 | 196 | if err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 186 | 197 | t.Errorf("err = %v, want it to contain %q", err, tc.want) | |
| 187 | 198 | } | |
| @@ -197,30 +208,30 @@ func TestMatrixLimit(t *testing.T) { | |||
| 197 | 208 | } | |
| 198 | 209 | return "[" + strings.Join(items, ",") + "]" | |
| 199 | 210 | } | |
| 200 | - | if c, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 { | |
| 211 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 { | |
| 201 | 212 | t.Errorf("256 jobs: %d, %v", len(c), err) | |
| 202 | 213 | } | |
| 203 | - | _, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`)) | |
| 214 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`)) | |
| 204 | 215 | if err == nil || !strings.Contains(err.Error(), "matrix size limit of 256 exceeded") { | |
| 205 | 216 | t.Errorf("257 jobs: %v", err) | |
| 206 | 217 | } | |
| 207 | - | _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) | |
| 218 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) | |
| 208 | 219 | if err == nil { | |
| 209 | 220 | t.Error("272 jobs: no error") | |
| 210 | 221 | } | |
| 211 | 222 | // 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 { | |
| 223 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`, "exclude": [{"a": 16}]}}`)); err != nil || len(c) != 256 { | |
| 213 | 224 | t.Errorf("272 minus 16: %d, %v", len(c), err) | |
| 214 | 225 | } | |
| 215 | 226 | // A huge product fails fast. | |
| 216 | - | _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`)) | |
| 227 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`)) | |
| 217 | 228 | if err == nil { | |
| 218 | 229 | t.Error("huge product: no error") | |
| 219 | 230 | } | |
| 220 | 231 | } | |
| 221 | 232 | ||
| 222 | 233 | func TestStrategyOptions(t *testing.T) { | |
| 223 | - | s, err := ParseStrategy(jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`)) | |
| 234 | + | s, err := ParseStrategy(ctxbg, jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`)) | |
| 224 | 235 | if err != nil { | |
| 225 | 236 | t.Fatal(err) | |
| 226 | 237 | } | |
| @@ -231,7 +242,7 @@ func TestStrategyOptions(t *testing.T) { | |||
| 231 | 242 | if got := expr.ToJSON(d); got != "{\n \"fail-fast\": false,\n \"job-index\": 1,\n \"job-total\": 3,\n \"max-parallel\": 2\n}" { | |
| 232 | 243 | t.Errorf("context = %s", got) | |
| 233 | 244 | } | |
| 234 | - | s, err = ParseStrategy(nil) | |
| 245 | + | s, err = ParseStrategy(ctxbg, nil) | |
| 235 | 246 | if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 { | |
| 236 | 247 | t.Errorf("default: %+v, %v", s, err) | |
| 237 | 248 | } | |
| @@ -247,3 +258,117 @@ func jsonValue2(s string) expr.Value { | |||
| 247 | 258 | } | |
| 248 | 259 | return v | |
| 249 | 260 | } | |
| 261 | + | ||
| 262 | + | func entries(n int) string { | |
| 263 | + | var items []string | |
| 264 | + | for i := 0; i < n; i++ { | |
| 265 | + | items = append(items, `{"a": 1}`) | |
| 266 | + | } | |
| 267 | + | return "[" + strings.Join(items, ",") + "]" | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestMatrixEntryLimitsAtTheEdge(t *testing.T) { | |
| 271 | + | if _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "exclude": `+strings.ReplaceAll(entries(256), `"a": 1`, `"a": 9`)+`}}`)); err != nil { | |
| 272 | + | t.Errorf("256 excludes: %v", err) | |
| 273 | + | } | |
| 274 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "include": `+entries(256)+`}}`)); err != nil || len(c) != 1 { | |
| 275 | + | t.Errorf("256 includes: %d, %v", len(c), err) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // TestHostileMatrixFailsFast is the probe of the review: many vectors whose | |
| 280 | + | // product is huge and whose exclusions hide the 256-job limit used to run for | |
| 281 | + | // close to a minute. | |
| 282 | + | func TestHostileMatrixFailsFast(t *testing.T) { | |
| 283 | + | vec := func(n int) string { | |
| 284 | + | var items []string | |
| 285 | + | for i := 0; i < n; i++ { | |
| 286 | + | items = append(items, fmt.Sprint(i)) | |
| 287 | + | } | |
| 288 | + | return "[" + strings.Join(items, ",") + "]" | |
| 289 | + | } | |
| 290 | + | src := `{"matrix": {"a": ` + vec(100) + `, "b": ` + vec(100) + `, "c": ` + vec(100) + `, "exclude": ` + strings.ReplaceAll(entries(256), `"a": 1`, `"c": 5`) + `}}` | |
| 291 | + | start := time.Now() | |
| 292 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, src)) | |
| 293 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |
| 294 | + | t.Errorf("err = %v", err) | |
| 295 | + | } | |
| 296 | + | if d := time.Since(start); d > time.Second { | |
| 297 | + | t.Errorf("took %v", d) | |
| 298 | + | } | |
| 299 | + | // Just under the product cap, with nothing to stop the loop early, ends. | |
| 300 | + | var parts []string | |
| 301 | + | for _, k := range []string{"a", "b", "c", "d"} { | |
| 302 | + | parts = append(parts, `"`+k+`": `+vec(16)) | |
| 303 | + | } | |
| 304 | + | start = time.Now() | |
| 305 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {`+strings.Join(parts, ",")+`, "exclude": [{"a": 0}, {"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}, {"a": 11}, {"a": 12}, {"a": 13}, {"a": 14}]}}`)) | |
| 306 | + | if err == nil { | |
| 307 | + | t.Error("65536 combinations of which 4096 remain: no error") | |
| 308 | + | } | |
| 309 | + | if d := time.Since(start); d > 2*time.Second { | |
| 310 | + | t.Errorf("took %v", d) | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestMatrixProductCap(t *testing.T) { | |
| 315 | + | vec := func(n int) string { | |
| 316 | + | var items []string | |
| 317 | + | for i := 0; i < n; i++ { | |
| 318 | + | items = append(items, fmt.Sprint(i)) | |
| 319 | + | } | |
| 320 | + | return "[" + strings.Join(items, ",") + "]" | |
| 321 | + | } | |
| 322 | + | // 2^16 is allowed by the cap (and fails on the job limit), 2^16+1 is refused outright. | |
| 323 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct/2)+`, "b": `+vec(2)+`, "c": `+vec(1)+`}}`)) | |
| 324 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |
| 325 | + | t.Errorf("err = %v", err) | |
| 326 | + | } | |
| 327 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct+1)+`}}`)) | |
| 328 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |
| 329 | + | t.Errorf("one huge vector: %v", err) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestMatrixContextIsHonoured(t *testing.T) { | |
| 334 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 335 | + | cancel() | |
| 336 | + | // 3000 combinations, nearly all excluded, so the job limit does not stop the loop. | |
| 337 | + | var items []string | |
| 338 | + | for i := 0; i < 3000; i++ { | |
| 339 | + | items = append(items, fmt.Sprint(i)) | |
| 340 | + | } | |
| 341 | + | _, err := ExpandMatrix(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`], "exclude": [{"a": 1}]}}`)) | |
| 342 | + | if !errors.Is(err, context.Canceled) { | |
| 343 | + | t.Errorf("err = %v", err) | |
| 344 | + | } | |
| 345 | + | _, err = ParseStrategy(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`]}}`)) | |
| 346 | + | if !errors.Is(err, context.Canceled) { | |
| 347 | + | t.Errorf("ParseStrategy err = %v", err) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestNullMatrixValueSegment(t *testing.T) { | |
| 352 | + | // TODO(verify with recording): a null value shows as an empty segment, | |
| 353 | + | // "job (, 1)"; GitHub may skip it. | |
| 354 | + | combos, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [null], "b": [1]}}`)) | |
| 355 | + | if err != nil { | |
| 356 | + | t.Fatal(err) | |
| 357 | + | } | |
| 358 | + | if got := combos[0].DisplayName("job"); got != "job (, 1)" { | |
| 359 | + | t.Errorf("name = %q", got) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestMatrixEnvBounds(t *testing.T) { | |
| 364 | + | s, _ := ParseStrategy(ctxbg, jsonValue(t, `{"matrix": {"a": [1, 2]}}`)) | |
| 365 | + | for _, i := range []int{-1, 2, 99} { | |
| 366 | + | if _, err := s.MatrixEnv(template.Env{}, i); err == nil { | |
| 367 | + | t.Errorf("MatrixEnv(%d): no error", i) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | j := &Job{Name: template.NewString(template.Origin{}, "n")} | |
| 371 | + | if _, err := j.DisplayName(ctxbg, template.Env{}, s, 5); err == nil { | |
| 372 | + | t.Error("DisplayName out of range: no error") | |
| 373 | + | } | |
| 374 | + | } | |
| @@ -3,14 +3,20 @@ | |||||
| 3 | package workflow | 3 | package workflow | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 6 | "fmt" | 8 | "fmt" | ||
| 7 | "reflect" | 9 | "reflect" | ||
| 8 | "strings" | 10 | "strings" | ||
| 9 | "testing" | 11 | "testing" | ||
| 12 | + | "time" | |||
| 10 | 13 | ||||
| 11 | "bnema.dev/ermite-actions/expr" | 14 | "bnema.dev/ermite-actions/expr" | ||
| 15 | + | "bnema.dev/ermite-actions/template" | |||
| 12 | ) | 16 | ) | ||
| 13 | 17 | ||||
| 18 | + | var ctxbg = context.Background() | |||
| 19 | + | ||||
| 14 | func jsonValue(t testing.TB, s string) expr.Value { | 20 | func jsonValue(t testing.TB, s string) expr.Value { | ||
| 15 | t.Helper() | 21 | t.Helper() | ||
| 16 | v, err := expr.FromJSON([]byte(s)) | 22 | v, err := expr.FromJSON([]byte(s)) | ||
| @@ -128,7 +134,7 @@ func TestExpandMatrix(t *testing.T) { | |||||
| 128 | }, | 134 | }, | ||
| 129 | { | 135 | { | ||
| 130 | "exclude no match", | 136 | "exclude no match", | ||
| 131 | - | `{"matrix": {"a": [1], "exclude": [{"a": 2}, {"zzz": 1}]}}`, | 137 | + | `{"matrix": {"a": [1], "exclude": [{"a": 2}]}}`, |
| 132 | []string{`a=1 | job (1)`}, | 138 | []string{`a=1 | job (1)`}, | ||
| 133 | }, | 139 | }, | ||
| 134 | { | 140 | { | ||
| @@ -154,7 +160,7 @@ func TestExpandMatrix(t *testing.T) { | |||||
| 154 | } | 160 | } | ||
| 155 | for _, tc := range tests { | 161 | for _, tc := range tests { | ||
| 156 | t.Run(tc.name, func(t *testing.T) { | 162 | t.Run(tc.name, func(t *testing.T) { | ||
| 157 | - | combos, err := ExpandMatrix(jsonValue(t, tc.strategy)) | 163 | + | combos, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy)) |
| 158 | if err != nil { | 164 | if err != nil { | ||
| 159 | t.Fatal(err) | 165 | t.Fatal(err) | ||
| 160 | } | 166 | } | ||
| @@ -174,6 +180,11 @@ func TestExpandMatrixErrors(t *testing.T) { | |||||
| 174 | {"include not a list", `{"matrix": {"include": 1}}`, "matrix include must be a sequence of mappings"}, | 180 | {"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"}, | 181 | {"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"}, | 182 | {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "matrix exclude must be a sequence of mappings"}, | ||
| 183 | + | {"exclude unknown key", `{"matrix": {"a": [1], "exclude": [{"zzz": 1}]}}`, "Matrix exclude key 'zzz' does not match any key within the matrix"}, | |||
| 184 | + | {"exclude include-only key", `{"matrix": {"a": [1], "include": [{"b": 1}], "exclude": [{"b": 1}]}}`, "Matrix exclude key 'b' does not match any key within the matrix"}, | |||
| 185 | + | {"exclude without vectors", `{"matrix": {"exclude": [{"a": 1}]}}`, "Matrix exclude key 'a' does not match any key within the matrix"}, | |||
| 186 | + | {"too many includes", `{"matrix": {"include": ` + entries(257) + `}}`, "more than 256 entries"}, | |||
| 187 | + | {"too many excludes", `{"matrix": {"a": [1], "exclude": ` + entries(257) + `}}`, "more than 256 entries"}, | |||
| 177 | {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"}, | 188 | {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"}, | ||
| 178 | {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"}, | 189 | {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"}, | ||
| 179 | {"max-parallel", `{"max-parallel": 0}`, "max-parallel"}, | 190 | {"max-parallel", `{"max-parallel": 0}`, "max-parallel"}, | ||
| @@ -181,7 +192,7 @@ func TestExpandMatrixErrors(t *testing.T) { | |||||
| 181 | } | 192 | } | ||
| 182 | for _, tc := range tests { | 193 | for _, tc := range tests { | ||
| 183 | t.Run(tc.name, func(t *testing.T) { | 194 | t.Run(tc.name, func(t *testing.T) { | ||
| 184 | - | _, err := ExpandMatrix(jsonValue(t, tc.strategy)) | 195 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy)) |
| 185 | if err == nil || !strings.Contains(err.Error(), tc.want) { | 196 | if err == nil || !strings.Contains(err.Error(), tc.want) { | ||
| 186 | t.Errorf("err = %v, want it to contain %q", err, tc.want) | 197 | t.Errorf("err = %v, want it to contain %q", err, tc.want) | ||
| 187 | } | 198 | } | ||
| @@ -197,30 +208,30 @@ func TestMatrixLimit(t *testing.T) { | |||||
| 197 | } | 208 | } | ||
| 198 | return "[" + strings.Join(items, ",") + "]" | 209 | return "[" + strings.Join(items, ",") + "]" | ||
| 199 | } | 210 | } | ||
| 200 | - | if c, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 { | 211 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 { |
| 201 | t.Errorf("256 jobs: %d, %v", len(c), err) | 212 | t.Errorf("256 jobs: %d, %v", len(c), err) | ||
| 202 | } | 213 | } | ||
| 203 | - | _, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`)) | 214 | + | _, err := ExpandMatrix(ctxbg, 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") { | 215 | if err == nil || !strings.Contains(err.Error(), "matrix size limit of 256 exceeded") { | ||
| 205 | t.Errorf("257 jobs: %v", err) | 216 | t.Errorf("257 jobs: %v", err) | ||
| 206 | } | 217 | } | ||
| 207 | - | _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) | 218 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) |
| 208 | if err == nil { | 219 | if err == nil { | ||
| 209 | t.Error("272 jobs: no error") | 220 | t.Error("272 jobs: no error") | ||
| 210 | } | 221 | } | ||
| 211 | // Excluding brings it back under the limit. | 222 | // 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 { | 223 | + | if c, err := ExpandMatrix(ctxbg, 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) | 224 | t.Errorf("272 minus 16: %d, %v", len(c), err) | ||
| 214 | } | 225 | } | ||
| 215 | // A huge product fails fast. | 226 | // A huge product fails fast. | ||
| 216 | - | _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`)) | 227 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`)) |
| 217 | if err == nil { | 228 | if err == nil { | ||
| 218 | t.Error("huge product: no error") | 229 | t.Error("huge product: no error") | ||
| 219 | } | 230 | } | ||
| 220 | } | 231 | } | ||
| 221 | 232 | ||||
| 222 | func TestStrategyOptions(t *testing.T) { | 233 | func TestStrategyOptions(t *testing.T) { | ||
| 223 | - | s, err := ParseStrategy(jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`)) | 234 | + | s, err := ParseStrategy(ctxbg, jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`)) |
| 224 | if err != nil { | 235 | if err != nil { | ||
| 225 | t.Fatal(err) | 236 | t.Fatal(err) | ||
| 226 | } | 237 | } | ||
| @@ -231,7 +242,7 @@ func TestStrategyOptions(t *testing.T) { | |||||
| 231 | if got := expr.ToJSON(d); got != "{\n \"fail-fast\": false,\n \"job-index\": 1,\n \"job-total\": 3,\n \"max-parallel\": 2\n}" { | 242 | 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) | 243 | t.Errorf("context = %s", got) | ||
| 233 | } | 244 | } | ||
| 234 | - | s, err = ParseStrategy(nil) | 245 | + | s, err = ParseStrategy(ctxbg, nil) |
| 235 | if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 { | 246 | if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 { | ||
| 236 | t.Errorf("default: %+v, %v", s, err) | 247 | t.Errorf("default: %+v, %v", s, err) | ||
| 237 | } | 248 | } | ||
| @@ -247,3 +258,117 @@ func jsonValue2(s string) expr.Value { | |||||
| 247 | } | 258 | } | ||
| 248 | return v | 259 | return v | ||
| 249 | } | 260 | } | ||
| 261 | + | ||||
| 262 | + | func entries(n int) string { | |||
| 263 | + | var items []string | |||
| 264 | + | for i := 0; i < n; i++ { | |||
| 265 | + | items = append(items, `{"a": 1}`) | |||
| 266 | + | } | |||
| 267 | + | return "[" + strings.Join(items, ",") + "]" | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestMatrixEntryLimitsAtTheEdge(t *testing.T) { | |||
| 271 | + | if _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "exclude": `+strings.ReplaceAll(entries(256), `"a": 1`, `"a": 9`)+`}}`)); err != nil { | |||
| 272 | + | t.Errorf("256 excludes: %v", err) | |||
| 273 | + | } | |||
| 274 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "include": `+entries(256)+`}}`)); err != nil || len(c) != 1 { | |||
| 275 | + | t.Errorf("256 includes: %d, %v", len(c), err) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // TestHostileMatrixFailsFast is the probe of the review: many vectors whose | |||
| 280 | + | // product is huge and whose exclusions hide the 256-job limit used to run for | |||
| 281 | + | // close to a minute. | |||
| 282 | + | func TestHostileMatrixFailsFast(t *testing.T) { | |||
| 283 | + | vec := func(n int) string { | |||
| 284 | + | var items []string | |||
| 285 | + | for i := 0; i < n; i++ { | |||
| 286 | + | items = append(items, fmt.Sprint(i)) | |||
| 287 | + | } | |||
| 288 | + | return "[" + strings.Join(items, ",") + "]" | |||
| 289 | + | } | |||
| 290 | + | src := `{"matrix": {"a": ` + vec(100) + `, "b": ` + vec(100) + `, "c": ` + vec(100) + `, "exclude": ` + strings.ReplaceAll(entries(256), `"a": 1`, `"c": 5`) + `}}` | |||
| 291 | + | start := time.Now() | |||
| 292 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, src)) | |||
| 293 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |||
| 294 | + | t.Errorf("err = %v", err) | |||
| 295 | + | } | |||
| 296 | + | if d := time.Since(start); d > time.Second { | |||
| 297 | + | t.Errorf("took %v", d) | |||
| 298 | + | } | |||
| 299 | + | // Just under the product cap, with nothing to stop the loop early, ends. | |||
| 300 | + | var parts []string | |||
| 301 | + | for _, k := range []string{"a", "b", "c", "d"} { | |||
| 302 | + | parts = append(parts, `"`+k+`": `+vec(16)) | |||
| 303 | + | } | |||
| 304 | + | start = time.Now() | |||
| 305 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {`+strings.Join(parts, ",")+`, "exclude": [{"a": 0}, {"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}, {"a": 11}, {"a": 12}, {"a": 13}, {"a": 14}]}}`)) | |||
| 306 | + | if err == nil { | |||
| 307 | + | t.Error("65536 combinations of which 4096 remain: no error") | |||
| 308 | + | } | |||
| 309 | + | if d := time.Since(start); d > 2*time.Second { | |||
| 310 | + | t.Errorf("took %v", d) | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestMatrixProductCap(t *testing.T) { | |||
| 315 | + | vec := func(n int) string { | |||
| 316 | + | var items []string | |||
| 317 | + | for i := 0; i < n; i++ { | |||
| 318 | + | items = append(items, fmt.Sprint(i)) | |||
| 319 | + | } | |||
| 320 | + | return "[" + strings.Join(items, ",") + "]" | |||
| 321 | + | } | |||
| 322 | + | // 2^16 is allowed by the cap (and fails on the job limit), 2^16+1 is refused outright. | |||
| 323 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct/2)+`, "b": `+vec(2)+`, "c": `+vec(1)+`}}`)) | |||
| 324 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |||
| 325 | + | t.Errorf("err = %v", err) | |||
| 326 | + | } | |||
| 327 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct+1)+`}}`)) | |||
| 328 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |||
| 329 | + | t.Errorf("one huge vector: %v", err) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func TestMatrixContextIsHonoured(t *testing.T) { | |||
| 334 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 335 | + | cancel() | |||
| 336 | + | // 3000 combinations, nearly all excluded, so the job limit does not stop the loop. | |||
| 337 | + | var items []string | |||
| 338 | + | for i := 0; i < 3000; i++ { | |||
| 339 | + | items = append(items, fmt.Sprint(i)) | |||
| 340 | + | } | |||
| 341 | + | _, err := ExpandMatrix(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`], "exclude": [{"a": 1}]}}`)) | |||
| 342 | + | if !errors.Is(err, context.Canceled) { | |||
| 343 | + | t.Errorf("err = %v", err) | |||
| 344 | + | } | |||
| 345 | + | _, err = ParseStrategy(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`]}}`)) | |||
| 346 | + | if !errors.Is(err, context.Canceled) { | |||
| 347 | + | t.Errorf("ParseStrategy err = %v", err) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func TestNullMatrixValueSegment(t *testing.T) { | |||
| 352 | + | // TODO(verify with recording): a null value shows as an empty segment, | |||
| 353 | + | // "job (, 1)"; GitHub may skip it. | |||
| 354 | + | combos, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [null], "b": [1]}}`)) | |||
| 355 | + | if err != nil { | |||
| 356 | + | t.Fatal(err) | |||
| 357 | + | } | |||
| 358 | + | if got := combos[0].DisplayName("job"); got != "job (, 1)" { | |||
| 359 | + | t.Errorf("name = %q", got) | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestMatrixEnvBounds(t *testing.T) { | |||
| 364 | + | s, _ := ParseStrategy(ctxbg, jsonValue(t, `{"matrix": {"a": [1, 2]}}`)) | |||
| 365 | + | for _, i := range []int{-1, 2, 99} { | |||
| 366 | + | if _, err := s.MatrixEnv(template.Env{}, i); err == nil { | |||
| 367 | + | t.Errorf("MatrixEnv(%d): no error", i) | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | j := &Job{Name: template.NewString(template.Origin{}, "n")} | |||
| 371 | + | if _, err := j.DisplayName(ctxbg, template.Env{}, s, 5); err == nil { | |||
| 372 | + | t.Error("DisplayName out of range: no error") | |||
| 373 | + | } | |||
| 374 | + | } | |||
A workflow/strategy.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-actions/expr" | |
| 13 | + | "bnema.dev/ermite-actions/template" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Strategy is an evaluated "strategy" key. | |
| 17 | + | type Strategy struct { | |
| 18 | + | // FailFast is true unless the file says otherwise. | |
| 19 | + | FailFast bool | |
| 20 | + | // MaxParallel is the limit of concurrent jobs, 0 for no limit. | |
| 21 | + | MaxParallel int | |
| 22 | + | // HasMatrix reports whether the strategy has a matrix. Without one, | |
| 23 | + | // Combos holds a single empty combination: the job runs once. | |
| 24 | + | HasMatrix bool | |
| 25 | + | // Combos are the jobs, in the order GitHub creates them. | |
| 26 | + | Combos []MatrixCombo | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // ParseStrategy reads an evaluated strategy value and expands its matrix, | |
| 30 | + | // see [ExpandMatrix] for the rules and limits. | |
| 31 | + | func ParseStrategy(ctx context.Context, v expr.Value) (*Strategy, error) { | |
| 32 | + | s := &Strategy{FailFast: true} | |
| 33 | + | var matrix expr.Value | |
| 34 | + | switch d := v.(type) { | |
| 35 | + | case nil, expr.Null: | |
| 36 | + | case *expr.Dictionary: | |
| 37 | + | if d == nil { | |
| 38 | + | break | |
| 39 | + | } | |
| 40 | + | for _, p := range d.Pairs() { | |
| 41 | + | switch strings.ToLower(p.Key) { | |
| 42 | + | case "fail-fast": | |
| 43 | + | b, err := strategyBool(p.Value) | |
| 44 | + | if err != nil { | |
| 45 | + | return nil, fmt.Errorf("unexpected value for 'fail-fast': %w", err) | |
| 46 | + | } | |
| 47 | + | s.FailFast = b | |
| 48 | + | case "max-parallel": | |
| 49 | + | n, err := strategyInt(p.Value) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, fmt.Errorf("unexpected value for 'max-parallel': %w", err) | |
| 52 | + | } | |
| 53 | + | s.MaxParallel = n | |
| 54 | + | case "matrix": | |
| 55 | + | matrix = p.Value | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | default: | |
| 59 | + | return nil, errors.New("the strategy must be a mapping") | |
| 60 | + | } | |
| 61 | + | combos, has, err := buildMatrix(ctx, matrix) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, err | |
| 64 | + | } | |
| 65 | + | s.Combos, s.HasMatrix = combos, has | |
| 66 | + | return s, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Context returns the "strategy" context of job number index (from 0). | |
| 70 | + | func (s *Strategy) Context(index int) *expr.Dictionary { | |
| 71 | + | d := expr.NewDictionary() | |
| 72 | + | d.Add("fail-fast", expr.Boolean(s.FailFast)) | |
| 73 | + | d.Add("job-index", expr.Number(index)) | |
| 74 | + | d.Add("job-total", expr.Number(len(s.Combos))) | |
| 75 | + | max := s.MaxParallel | |
| 76 | + | if max == 0 { | |
| 77 | + | max = len(s.Combos) | |
| 78 | + | } | |
| 79 | + | d.Add("max-parallel", expr.Number(max)) | |
| 80 | + | return d | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func strategyBool(v expr.Value) (bool, error) { | |
| 84 | + | switch x := v.(type) { | |
| 85 | + | case expr.Boolean: | |
| 86 | + | return bool(x), nil | |
| 87 | + | case expr.String: | |
| 88 | + | switch strings.ToLower(string(x)) { | |
| 89 | + | case "true": | |
| 90 | + | return true, nil | |
| 91 | + | case "false": | |
| 92 | + | return false, nil | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return false, fmt.Errorf("expected a boolean but got %s", kindName(v)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func strategyInt(v expr.Value) (int, error) { | |
| 99 | + | var f float64 | |
| 100 | + | switch x := v.(type) { | |
| 101 | + | case expr.Number: | |
| 102 | + | f = float64(x) | |
| 103 | + | case expr.String: | |
| 104 | + | n, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64) | |
| 105 | + | if err != nil { | |
| 106 | + | return 0, fmt.Errorf("expected a number but got '%s'", string(x)) | |
| 107 | + | } | |
| 108 | + | f = n | |
| 109 | + | default: | |
| 110 | + | return 0, fmt.Errorf("expected a number but got %s", kindName(v)) | |
| 111 | + | } | |
| 112 | + | if f != float64(int(f)) || f < 1 || f > 1<<31 { | |
| 113 | + | return 0, errors.New("expected a whole number greater than 0") | |
| 114 | + | } | |
| 115 | + | return int(f), nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func kindName(v expr.Value) string { | |
| 119 | + | if v == nil { | |
| 120 | + | return expr.KindNull.String() | |
| 121 | + | } | |
| 122 | + | return v.Kind().String() | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // EvaluateStrategy evaluates the job's strategy token against env and | |
| 126 | + | // expands its matrix. env must provide the contexts the schema allows in | |
| 127 | + | // "strategy": github, inputs, vars and needs. A job without a strategy gives | |
| 128 | + | // a Strategy with one empty combination. | |
| 129 | + | func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) { | |
| 130 | + | if j.Strategy == nil { | |
| 131 | + | return ParseStrategy(ctx, nil) | |
| 132 | + | } | |
| 133 | + | v, err := template.Evaluate(ctx, j.Strategy, env) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, err | |
| 136 | + | } | |
| 137 | + | return ParseStrategy(ctx, v) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // MatrixEnv returns env with the "matrix" and "strategy" contexts of | |
| 141 | + | // combination index added, for evaluating the parts of a job that may use | |
| 142 | + | // them: runs-on, name, env, container, services, steps... It fails when index | |
| 143 | + | // is out of range. | |
| 144 | + | func (s *Strategy) MatrixEnv(env template.Env, index int) (template.Env, error) { | |
| 145 | + | if index < 0 || index >= len(s.Combos) { | |
| 146 | + | return env, fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos)) | |
| 147 | + | } | |
| 148 | + | out := env | |
| 149 | + | out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2) | |
| 150 | + | for k, v := range env.Contexts { | |
| 151 | + | out.Contexts[k] = v | |
| 152 | + | } | |
| 153 | + | out.Contexts["matrix"] = s.Combos[index].Values | |
| 154 | + | out.Contexts["strategy"] = s.Context(index) | |
| 155 | + | return out, nil | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // DisplayName evaluates the job's name for combination index of its | |
| 159 | + | // strategy: the name token (or the job id) with the matrix values added in | |
| 160 | + | // parentheses, unless the name is set and reads the matrix context, in which | |
| 161 | + | // case it is used as is. env must already include the matrix and strategy | |
| 162 | + | // contexts, see [Strategy.MatrixEnv]. | |
| 163 | + | // | |
| 164 | + | // TODO(verify with recording): GitHub adds the values to the name unless the | |
| 165 | + | // name is set and refers to the matrix. | |
| 166 | + | func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) { | |
| 167 | + | if index < 0 || index >= len(s.Combos) { | |
| 168 | + | return "", fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos)) | |
| 169 | + | } | |
| 170 | + | name, err := template.EvaluateString(ctx, j.Name, env) | |
| 171 | + | if err != nil { | |
| 172 | + | return "", err | |
| 173 | + | } | |
| 174 | + | if !s.HasMatrix || (j.HasName && usesMatrix(j.Name)) { | |
| 175 | + | return name, nil | |
| 176 | + | } | |
| 177 | + | return s.Combos[index].DisplayName(name), nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // usesMatrix reports whether an expression in t reads the matrix context. | |
| 181 | + | func usesMatrix(t template.Token) bool { | |
| 182 | + | found := false | |
| 183 | + | template.Walk(t, func(x template.Token) bool { | |
| 184 | + | if be, ok := x.(*template.BasicExpression); ok && be.UsesContext("matrix") { | |
| 185 | + | found = true | |
| 186 | + | } | |
| 187 | + | return !found | |
| 188 | + | }) | |
| 189 | + | return found | |
| 190 | + | } | |
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite-actions/expr" | |||
| 13 | + | "bnema.dev/ermite-actions/template" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Strategy is an evaluated "strategy" key. | |||
| 17 | + | type Strategy struct { | |||
| 18 | + | // FailFast is true unless the file says otherwise. | |||
| 19 | + | FailFast bool | |||
| 20 | + | // MaxParallel is the limit of concurrent jobs, 0 for no limit. | |||
| 21 | + | MaxParallel int | |||
| 22 | + | // HasMatrix reports whether the strategy has a matrix. Without one, | |||
| 23 | + | // Combos holds a single empty combination: the job runs once. | |||
| 24 | + | HasMatrix bool | |||
| 25 | + | // Combos are the jobs, in the order GitHub creates them. | |||
| 26 | + | Combos []MatrixCombo | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // ParseStrategy reads an evaluated strategy value and expands its matrix, | |||
| 30 | + | // see [ExpandMatrix] for the rules and limits. | |||
| 31 | + | func ParseStrategy(ctx context.Context, v expr.Value) (*Strategy, error) { | |||
| 32 | + | s := &Strategy{FailFast: true} | |||
| 33 | + | var matrix expr.Value | |||
| 34 | + | switch d := v.(type) { | |||
| 35 | + | case nil, expr.Null: | |||
| 36 | + | case *expr.Dictionary: | |||
| 37 | + | if d == nil { | |||
| 38 | + | break | |||
| 39 | + | } | |||
| 40 | + | for _, p := range d.Pairs() { | |||
| 41 | + | switch strings.ToLower(p.Key) { | |||
| 42 | + | case "fail-fast": | |||
| 43 | + | b, err := strategyBool(p.Value) | |||
| 44 | + | if err != nil { | |||
| 45 | + | return nil, fmt.Errorf("unexpected value for 'fail-fast': %w", err) | |||
| 46 | + | } | |||
| 47 | + | s.FailFast = b | |||
| 48 | + | case "max-parallel": | |||
| 49 | + | n, err := strategyInt(p.Value) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return nil, fmt.Errorf("unexpected value for 'max-parallel': %w", err) | |||
| 52 | + | } | |||
| 53 | + | s.MaxParallel = n | |||
| 54 | + | case "matrix": | |||
| 55 | + | matrix = p.Value | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | default: | |||
| 59 | + | return nil, errors.New("the strategy must be a mapping") | |||
| 60 | + | } | |||
| 61 | + | combos, has, err := buildMatrix(ctx, matrix) | |||
| 62 | + | if err != nil { | |||
| 63 | + | return nil, err | |||
| 64 | + | } | |||
| 65 | + | s.Combos, s.HasMatrix = combos, has | |||
| 66 | + | return s, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Context returns the "strategy" context of job number index (from 0). | |||
| 70 | + | func (s *Strategy) Context(index int) *expr.Dictionary { | |||
| 71 | + | d := expr.NewDictionary() | |||
| 72 | + | d.Add("fail-fast", expr.Boolean(s.FailFast)) | |||
| 73 | + | d.Add("job-index", expr.Number(index)) | |||
| 74 | + | d.Add("job-total", expr.Number(len(s.Combos))) | |||
| 75 | + | max := s.MaxParallel | |||
| 76 | + | if max == 0 { | |||
| 77 | + | max = len(s.Combos) | |||
| 78 | + | } | |||
| 79 | + | d.Add("max-parallel", expr.Number(max)) | |||
| 80 | + | return d | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func strategyBool(v expr.Value) (bool, error) { | |||
| 84 | + | switch x := v.(type) { | |||
| 85 | + | case expr.Boolean: | |||
| 86 | + | return bool(x), nil | |||
| 87 | + | case expr.String: | |||
| 88 | + | switch strings.ToLower(string(x)) { | |||
| 89 | + | case "true": | |||
| 90 | + | return true, nil | |||
| 91 | + | case "false": | |||
| 92 | + | return false, nil | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | return false, fmt.Errorf("expected a boolean but got %s", kindName(v)) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func strategyInt(v expr.Value) (int, error) { | |||
| 99 | + | var f float64 | |||
| 100 | + | switch x := v.(type) { | |||
| 101 | + | case expr.Number: | |||
| 102 | + | f = float64(x) | |||
| 103 | + | case expr.String: | |||
| 104 | + | n, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return 0, fmt.Errorf("expected a number but got '%s'", string(x)) | |||
| 107 | + | } | |||
| 108 | + | f = n | |||
| 109 | + | default: | |||
| 110 | + | return 0, fmt.Errorf("expected a number but got %s", kindName(v)) | |||
| 111 | + | } | |||
| 112 | + | if f != float64(int(f)) || f < 1 || f > 1<<31 { | |||
| 113 | + | return 0, errors.New("expected a whole number greater than 0") | |||
| 114 | + | } | |||
| 115 | + | return int(f), nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func kindName(v expr.Value) string { | |||
| 119 | + | if v == nil { | |||
| 120 | + | return expr.KindNull.String() | |||
| 121 | + | } | |||
| 122 | + | return v.Kind().String() | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // EvaluateStrategy evaluates the job's strategy token against env and | |||
| 126 | + | // expands its matrix. env must provide the contexts the schema allows in | |||
| 127 | + | // "strategy": github, inputs, vars and needs. A job without a strategy gives | |||
| 128 | + | // a Strategy with one empty combination. | |||
| 129 | + | func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) { | |||
| 130 | + | if j.Strategy == nil { | |||
| 131 | + | return ParseStrategy(ctx, nil) | |||
| 132 | + | } | |||
| 133 | + | v, err := template.Evaluate(ctx, j.Strategy, env) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, err | |||
| 136 | + | } | |||
| 137 | + | return ParseStrategy(ctx, v) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // MatrixEnv returns env with the "matrix" and "strategy" contexts of | |||
| 141 | + | // combination index added, for evaluating the parts of a job that may use | |||
| 142 | + | // them: runs-on, name, env, container, services, steps... It fails when index | |||
| 143 | + | // is out of range. | |||
| 144 | + | func (s *Strategy) MatrixEnv(env template.Env, index int) (template.Env, error) { | |||
| 145 | + | if index < 0 || index >= len(s.Combos) { | |||
| 146 | + | return env, fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos)) | |||
| 147 | + | } | |||
| 148 | + | out := env | |||
| 149 | + | out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2) | |||
| 150 | + | for k, v := range env.Contexts { | |||
| 151 | + | out.Contexts[k] = v | |||
| 152 | + | } | |||
| 153 | + | out.Contexts["matrix"] = s.Combos[index].Values | |||
| 154 | + | out.Contexts["strategy"] = s.Context(index) | |||
| 155 | + | return out, nil | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // DisplayName evaluates the job's name for combination index of its | |||
| 159 | + | // strategy: the name token (or the job id) with the matrix values added in | |||
| 160 | + | // parentheses, unless the name is set and reads the matrix context, in which | |||
| 161 | + | // case it is used as is. env must already include the matrix and strategy | |||
| 162 | + | // contexts, see [Strategy.MatrixEnv]. | |||
| 163 | + | // | |||
| 164 | + | // TODO(verify with recording): GitHub adds the values to the name unless the | |||
| 165 | + | // name is set and refers to the matrix. | |||
| 166 | + | func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) { | |||
| 167 | + | if index < 0 || index >= len(s.Combos) { | |||
| 168 | + | return "", fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos)) | |||
| 169 | + | } | |||
| 170 | + | name, err := template.EvaluateString(ctx, j.Name, env) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return "", err | |||
| 173 | + | } | |||
| 174 | + | if !s.HasMatrix || (j.HasName && usesMatrix(j.Name)) { | |||
| 175 | + | return name, nil | |||
| 176 | + | } | |||
| 177 | + | return s.Combos[index].DisplayName(name), nil | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // usesMatrix reports whether an expression in t reads the matrix context. | |||
| 181 | + | func usesMatrix(t template.Token) bool { | |||
| 182 | + | found := false | |||
| 183 | + | template.Walk(t, func(x template.Token) bool { | |||
| 184 | + | if be, ok := x.(*template.BasicExpression); ok && be.UsesContext("matrix") { | |||
| 185 | + | found = true | |||
| 186 | + | } | |||
| 187 | + | return !found | |||
| 188 | + | }) | |||
| 189 | + | return found | |||
| 190 | + | } | |||