detached 98594d0

strategy.go RawHistoryFolder

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

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