detached fa5b491

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
7272 continue
7373 }
7474 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)
9077 }
9178 case "labels":
9279 runsOnLabels(p.Value)
@@ -72,21 +72,8 @@ func (c *converter) convertRunsOn(t template.Token) {
72 continue72 continue
73 }73 }
74 group := asString(p.Value, "job runs-on group name").Value74 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:
6565 names := make([]string, len(s.Combos))
6666 runsOn := make([]string, len(s.Combos))
6767 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+ }
6972 names[i], err = job.DisplayName(context.Background(), e, s, i)
7073 if err != nil {
7174 t.Fatal(err)
@@ -88,7 +91,7 @@ jobs:
8891 // The strategy context.
8992 schema, _ := template.WorkflowSchema()
9093 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" {
9295 t.Errorf("strategy context = %q, %v", got, err)
9396 }
9497 }
@@ -113,7 +116,7 @@ jobs:
113116 if err != nil {
114117 t.Fatal(err)
115118 }
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" {
117120 t.Errorf("name = %q", got)
118121 }
119122 b := w.Job("b")
@@ -179,3 +182,12 @@ jobs:
179182 t.Errorf("step ifs = %s, %s", b.Steps[0].If.Expression, b.Steps[1].If.Expression)
180183 }
181184 }
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) {
4141 if v == nil {
4242 return
4343 }
44- _, _ = ExpandMatrix(v)
44+ _, _ = ExpandMatrix(ctxbg, v)
4545 })
4646 }
4747
@@ -41,7 +41,7 @@ func FuzzExpandMatrix(f *testing.F) {
41 if v == nil {41 if v == nil {
42 return42 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 @@
33 package workflow
44
55 import (
6+ "context"
67 "errors"
78 "fmt"
8- "strconv"
99 "strings"
1010
1111 "bnema.dev/ermite-actions/expr"
1212 )
1313
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+)
1626
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
2129
2230 // MatrixCombo is one job of a matrix.
2331 type MatrixCombo struct {
@@ -27,7 +35,7 @@ type MatrixCombo struct {
2735 Values *expr.Dictionary
2836 // Segments are the scalar values of Values, in order, shown in the name
2937 // of the job: "build (ubuntu-latest, 14)". Arrays and mappings are left
30- // out.
38+ // out; null shows as an empty segment.
3139 Segments []string
3240 }
3341
@@ -40,19 +48,6 @@ func (c MatrixCombo) DisplayName(base string) string {
4048 return base + " (" + strings.Join(c.Segments, ", ") + ")"
4149 }
4250
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-
5651 // ExpandMatrix expands the matrix of an evaluated strategy (a dictionary with
5752 // the optional keys "matrix", "fail-fast" and "max-parallel", or null) into
5853 // the job combinations, like GitHub:
@@ -60,7 +55,8 @@ type Strategy struct {
6055 // - the combinations are the cross product of the vectors, the first key
6156 // varying slowest;
6257 // - 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;
6460 // - then each include entry is added to every remaining combination whose
6561 // values for the vector keys it mentions are equal (an entry that
6662 // mentions no vector key matches all), without overwriting the original
@@ -69,118 +65,29 @@ type Strategy struct {
6965 // - more than 256 jobs is an error. A strategy without a matrix gives a
7066 // single combination.
7167 //
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+//
7272 // TODO(verify with recording): the documented semantics of include and
7373 // exclude are implemented as documented, but the following are assumed: the
7474 // order of the cross product (first key slowest), the display name listing
7575 // 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)
8284 if err != nil {
8385 return nil, err
8486 }
8587 return s.Combos, nil
8688 }
8789
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) {
18491 switch m.(type) {
18592 case nil, expr.Null:
18693 return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
@@ -192,8 +99,7 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) {
19299 if md == nil {
193100 return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
194101 }
195- var keys []string
196- vectors := map[string][]expr.Value{}
102+ var vecs []vector
197103 var includes, excludes []*expr.Dictionary
198104 for _, p := range md.Pairs() {
199105 switch p.Key {
@@ -211,17 +117,16 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) {
211117 excludes = list
212118 default:
213119 arr, ok := p.Value.(*expr.Array)
214- if !ok || arr == nil {
120+ if !ok || arr == nil || arr.Len() == 0 {
215121 return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key)
216122 }
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
219125 }
220- keys = append(keys, p.Key)
221- vectors[p.Key] = arr.Items()
126+ vecs = append(vecs, vector{key: p.Key, items: arr.Items()})
222127 }
223128 }
224- return expand(keys, vectors, includes, excludes)
129+ return expand(ctx, vecs, includes, excludes)
225130 }
226131
227132 func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) {
@@ -229,6 +134,9 @@ func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) {
229134 if !ok || arr == nil {
230135 return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name)
231136 }
137+ if arr.Len() > MaxMatrixEntries {
138+ return nil, fmt.Errorf("matrix %s has more than %d entries", name, MaxMatrixEntries)
139+ }
232140 var out []*expr.Dictionary
233141 for _, it := range arr.Items() {
234142 d, ok := it.(*expr.Dictionary)
@@ -240,72 +148,119 @@ func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) {
240148 return out, nil
241149 }
242150
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
249161 }
250162
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)
255179 }
256- b.vals[lk] = v
180+ return c
257181 }
258182
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+ }
267212 }
268213 }
269- return MatrixCombo{Values: d, Segments: segs}
270-}
271214
272-func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) {
273215 var combos []MatrixCombo
274216 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 {
277218 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
282223 }
283224 }
225+ tuple := make([]int, len(vecs))
226+ next:
284227 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+ }
290247 }
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]])
294251 }
295- for n, inc := range includes {
296- if !includeMatches(base, keys, inc) {
252+ for n, inc := range incl {
253+ if !allHold(inc.conds, tuple) {
297254 continue
298255 }
299256 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)
304259 }
305260 }
306261 combos = append(combos, b.combo())
307262 if len(combos) > MaxMatrixJobs {
308- return nil, false, tooBig
263+ return nil, false, errTooBig
309264 }
310265 }
311266 }
@@ -319,11 +274,11 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [
319274 }
320275 combos = append(combos, b.combo())
321276 if len(combos) > MaxMatrixJobs {
322- return nil, false, tooBig
277+ return nil, false, errTooBig
323278 }
324279 }
325280 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 {
327282 // An empty matrix runs the job once.
328283 return []MatrixCombo{{Values: expr.NewDictionary()}}, true, nil
329284 }
@@ -334,50 +289,42 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [
334289 return combos, true, nil
335290 }
336291
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
341296 }
342297 }
343- return false
298+ return true
344299 }
345300
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)
363313 }
364- return false
314+ b.vals[lk] = v
365315 }
366316
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)
378325 }
379326 }
380- return true
327+ return MatrixCombo{Values: d, Segments: segs}
381328 }
382329
383330 // valuesMatch compares a matrix value with the value in an include or
@@ -3,21 +3,29 @@
3 package workflow3 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 = 25615+// 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.Dictionary35 Values *expr.Dictionary
28 // Segments are the scalar values of Values, in order, shown in the name36 // 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 left37 // of the job: "build (ubuntu-latest, 14)". Arrays and mappings are left
30- // out.38+ // out; null shows as an empty segment.
31 Segments []string39 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 with51 // ExpandMatrix expands the matrix of an evaluated strategy (a dictionary with
57 // the optional keys "matrix", "fail-fast" and "max-parallel", or null) into52 // 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 key55 // - 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-value57 // - 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 whose60 // - then each include entry is added to every remaining combination whose
65 // values for the vector keys it mentions are equal (an entry that61 // values for the vector keys it mentions are equal (an entry that
66 // mentions no vector key matches all), without overwriting the original62 // 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 a65 // - 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 and72 // TODO(verify with recording): the documented semantics of include and
73 // exclude are implemented as documented, but the following are assumed: the73 // exclude are implemented as documented, but the following are assumed: the
74 // order of the cross product (first key slowest), the display name listing74 // order of the cross product (first key slowest), the display name listing
75 // every scalar value including those added by include (see75 // every scalar value including those added by include (see
76-// [MatrixCombo.DisplayName]), the comparison of values (same kind, strings76+// [MatrixCombo.DisplayName]) and showing null as an empty segment, the
77-// compared exactly, mappings by containment), an include key matching a77+// comparison of values (same kind, strings compared exactly, mappings by
78-// vector key case-insensitively, the error when every combination is78+// 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, err85 return nil, err
84 }86 }
85 return s.Combos, nil87 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, nil93 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, nil100 return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
194 }101 }
195- var keys []string102+ var vecs []vector
196- vectors := map[string][]expr.Value{}
197 var includes, excludes []*expr.Dictionary103 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 = list117 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.Dictionary140 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, nil148 return out, nil
241 }149 }
242 150
243-// comboBuilder holds the values of one combination while includes are151+// 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 ones152+type message string
245-// never are.153+
246-type comboBuilder struct {154+func (m message) Error() string { return string(m) }
247- keys []string155+
248- vals map[string]expr.Value // by lower-cased key156+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] = v180+ 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 []string185+ 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 []MatrixCombo215 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 := 1218 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, tooBig222+ 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 := product229+ 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().Values248+ b := &comboBuilder{vals: map[string]expr.Value{}}
292- if excluded(base, excludes) {249+ for k, v := range vecs {
293- continue250+ 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 continue254 continue
298 }255 }
299 matched[n] = true256 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, tooBig263+ 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, tooBig277+ 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, nil283 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, nil289 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 true295+ return false
341 }296 }
342 }297 }
343- return false298+ 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- continue305+ keys []string
351- }306+ vals map[string]expr.Value // by lower-cased key
352- all := true307+}
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 = false311+ if _, ok := b.vals[lk]; !ok {
357- break312+ b.keys = append(b.keys, key)
358- }
359- }
360- if all {
361- return true
362- }
363 }313 }
364- return false314+ b.vals[lk] = v
365 }315 }
366 316
367-// includeMatches reports whether an include entry applies to vals: every key317+func (b *comboBuilder) combo() MatrixCombo {
368-// of the entry that is a vector of the matrix has the same value. An entry318+ 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- continue323+ 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 true327+ return MatrixCombo{Values: d, Segments: segs}
381 }328 }
382 329
383 // valuesMatch compares a matrix value with the value in an include or330 // valuesMatch compares a matrix value with the value in an include or
M workflow/matrix_test.go +135 -10
@@ -3,14 +3,20 @@
33 package workflow
44
55 import (
6+ "context"
7+ "errors"
68 "fmt"
79 "reflect"
810 "strings"
911 "testing"
12+ "time"
1013
1114 "bnema.dev/ermite-actions/expr"
15+ "bnema.dev/ermite-actions/template"
1216 )
1317
18+var ctxbg = context.Background()
19+
1420 func jsonValue(t testing.TB, s string) expr.Value {
1521 t.Helper()
1622 v, err := expr.FromJSON([]byte(s))
@@ -128,7 +134,7 @@ func TestExpandMatrix(t *testing.T) {
128134 },
129135 {
130136 "exclude no match",
131- `{"matrix": {"a": [1], "exclude": [{"a": 2}, {"zzz": 1}]}}`,
137+ `{"matrix": {"a": [1], "exclude": [{"a": 2}]}}`,
132138 []string{`a=1 | job (1)`},
133139 },
134140 {
@@ -154,7 +160,7 @@ func TestExpandMatrix(t *testing.T) {
154160 }
155161 for _, tc := range tests {
156162 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))
158164 if err != nil {
159165 t.Fatal(err)
160166 }
@@ -174,6 +180,11 @@ func TestExpandMatrixErrors(t *testing.T) {
174180 {"include not a list", `{"matrix": {"include": 1}}`, "matrix include must be a sequence of mappings"},
175181 {"include item", `{"matrix": {"include": [1]}}`, "matrix include must be a sequence of mappings"},
176182 {"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"},
177188 {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"},
178189 {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"},
179190 {"max-parallel", `{"max-parallel": 0}`, "max-parallel"},
@@ -181,7 +192,7 @@ func TestExpandMatrixErrors(t *testing.T) {
181192 }
182193 for _, tc := range tests {
183194 t.Run(tc.name, func(t *testing.T) {
184- _, err := ExpandMatrix(jsonValue(t, tc.strategy))
195+ _, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy))
185196 if err == nil || !strings.Contains(err.Error(), tc.want) {
186197 t.Errorf("err = %v, want it to contain %q", err, tc.want)
187198 }
@@ -197,30 +208,30 @@ func TestMatrixLimit(t *testing.T) {
197208 }
198209 return "[" + strings.Join(items, ",") + "]"
199210 }
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 {
201212 t.Errorf("256 jobs: %d, %v", len(c), err)
202213 }
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}]}}`))
204215 if err == nil || !strings.Contains(err.Error(), "matrix size limit of 256 exceeded") {
205216 t.Errorf("257 jobs: %v", err)
206217 }
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)+`}}`))
208219 if err == nil {
209220 t.Error("272 jobs: no error")
210221 }
211222 // 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 {
213224 t.Errorf("272 minus 16: %d, %v", len(c), err)
214225 }
215226 // 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)+`}}`))
217228 if err == nil {
218229 t.Error("huge product: no error")
219230 }
220231 }
221232
222233 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]}}`))
224235 if err != nil {
225236 t.Fatal(err)
226237 }
@@ -231,7 +242,7 @@ func TestStrategyOptions(t *testing.T) {
231242 if got := expr.ToJSON(d); got != "{\n \"fail-fast\": false,\n \"job-index\": 1,\n \"job-total\": 3,\n \"max-parallel\": 2\n}" {
232243 t.Errorf("context = %s", got)
233244 }
234- s, err = ParseStrategy(nil)
245+ s, err = ParseStrategy(ctxbg, nil)
235246 if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 {
236247 t.Errorf("default: %+v, %v", s, err)
237248 }
@@ -247,3 +258,117 @@ func jsonValue2(s string) expr.Value {
247258 }
248259 return v
249260 }
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 workflow3 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 v259 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+}

Keyboard shortcuts

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

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

Branches 1

Tags 3

↑↓ moveenter openesc close