v0.1.0 cbb8ad6

step.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"strings"
10	"sync"
11
12	"bnema.dev/ermite-actions/expr"
13	"bnema.dev/ermite-actions/template"
14	"bnema.dev/ermite-actions/workflow"
15)
16
17// ---------------------------------------------------------------- steps
18
19// stepRun is the state of one step being run; it implements Sink and
20// commandHost.
21type stepRun struct {
22	j         *job
23	num       int
24	st        *workflow.Step
25	env       *ordered // the step's environment: job env + step env
26	cmdResult *stepResult
27	outcome   *stepResult
28	cmds      *commandManager
29}
30
31func (s *stepRun) Out(text string)        { s.j.out(s.num, text) }
32func (s *stepRun) Tagged(tag, msg string) { s.j.tagged(s.num, tag, msg) }
33func (s *stepRun) Debug(msg string)       { s.j.debug(s.num, msg) }
34func (s *stepRun) Error(msg string)       { s.j.errorf(s.num, "%s", msg) }
35func (s *stepRun) CommandFailed()         { f := resFailed; s.cmdResult = &f }
36func (s *stepRun) SetOutput(name, value string) {
37	s.j.mu.Lock()
38	s.j.state.SetOutput(s.st.ID, name, value)
39	s.j.mu.Unlock()
40}
41func (s *stepRun) SaveState(name, value string) {}
42func (s *stepRun) SetEnv(name, value string) {
43	s.j.mu.Lock()
44	s.j.state.SetEnv(name, value)
45	s.j.mu.Unlock()
46}
47func (s *stepRun) AddPath(p string) {
48	s.j.mu.Lock()
49	s.j.state.AddPath(p)
50	s.j.mu.Unlock()
51}
52
53// EnvFlag reads a boolean variable of the env context, then of the process
54// (ActionCommandManager.cs:236-246).
55func (s *stepRun) EnvFlag(name string) bool {
56	v := strings.ToLower(envValue(s.j.baseEnv, name))
57	if v != "true" {
58		if e, ok := s.env.Get(name); ok {
59			v = strings.ToLower(e)
60		}
61	}
62	return v == "true"
63}
64
65func firstLine(contents string) string {
66	l := strings.TrimLeft(contents, " \t\r\n")
67	if i := strings.IndexAny(l, "\r\n"); i >= 0 {
68		l = l[:i]
69	}
70	return l
71}
72
73// displayName is the name shown for the step (ActionRunner.GenerateDisplayName,
74// ActionRunner.cs:363): the evaluated name, or "Run " and the first line of
75// the script or the action reference.
76func (j *job) displayName(st *workflow.Step, env *ordered) string {
77	tok := st.Name
78	prefix := ""
79	switch {
80	case tok != nil:
81	case st.Run != nil:
82		tok, prefix = st.Run, "Run "
83	case st.Uses != nil:
84		tok, prefix = st.Uses, "Run "
85	default:
86		return ""
87	}
88	// JobExtension evaluates the display names once, when the job starts, with
89	// the contexts the server sent (github.job is empty there) and without the
90	// step functions; ActionRunner.GenerateDisplayName only does it when every
91	// context and function the name uses is available (CheckHasRequiredContext).
92	// A name that is fully evaluated then stays as it is (recorded:
93	// ctx-github-names, "Run echo \"job=\""); the others are evaluated again,
94	// with the contexts of the step, when the step starts.
95	var name string
96	var err error
97	if serverEvaluable(tok) {
98		j.mu.Lock()
99		sc := j.state.ServerContexts()
100		j.mu.Unlock()
101		name, err = template.EvaluateString(context.Background(), tok, template.Env{Contexts: sc})
102	} else {
103		name, err = j.evalString(tok, env)
104	}
105	if err != nil {
106		name = tokenSource(tok)
107	}
108	return formatStepName(prefix, name)
109}
110
111// serverContextNames are the contexts of the job message (ContextData) when
112// display names are first evaluated.
113var serverContextNames = map[string]bool{"github": true, "needs": true, "strategy": true, "matrix": true, "inputs": true, "vars": true}
114
115// wellKnownFuncs are the functions available without the step functions.
116var wellKnownFuncs = map[string]bool{"case": true, "contains": true, "endswith": true, "format": true, "join": true, "startswith": true, "tojson": true, "fromjson": true}
117
118// serverEvaluable is TemplateTokenExtensions.CheckHasRequiredContext for the
119// first evaluation of a display name: every expression of the token only
120// reads contexts of the job message and calls well-known functions.
121func serverEvaluable(tok template.Token) bool {
122	ok := true
123	template.Walk(tok, func(t template.Token) bool {
124		if be, isExpr := t.(*template.BasicExpression); isExpr {
125			ok = ok && nodeServerEvaluable(be.Node())
126		}
127		return ok
128	})
129	return ok
130}
131
132func nodeServerEvaluable(n expr.Node) bool {
133	switch n := n.(type) {
134	case *expr.ContextAccess:
135		return serverContextNames[strings.ToLower(n.Name.Lexeme)]
136	case *expr.Unary:
137		return nodeServerEvaluable(n.Expr)
138	case *expr.Binary:
139		return nodeServerEvaluable(n.Left) && nodeServerEvaluable(n.Right)
140	case *expr.Grouping:
141		return nodeServerEvaluable(n.Group)
142	case *expr.IndexAccess:
143		return nodeServerEvaluable(n.Expr) && nodeServerEvaluable(n.Index)
144	case *expr.Logical:
145		for _, a := range n.Args {
146			if !nodeServerEvaluable(a) {
147				return false
148			}
149		}
150	case *expr.FunctionCall:
151		if !wellKnownFuncs[strings.ToLower(n.Name.Lexeme)] {
152			return false
153		}
154		for _, a := range n.Args {
155			if !nodeServerEvaluable(a) {
156				return false
157			}
158		}
159	}
160	return true
161}
162
163// exceptionClasser is implemented by the errors of package expr that stand for
164// a .NET exception (expr.JSONError).
165type exceptionClasser interface{ ExceptionClass() string }
166
167// templateNotValid is the message of the TemplateValidationException the
168// runner throws when the evaluation of a template fails
169// (TemplateStrings.TemplateNotValidWithErrors): "The template is not valid. "
170// and the errors. With a position the error reads "file (Line: l, Col: c):
171// message" (recorded: expr-runtime-errors, step env and run). A step condition
172// has no position: the error is the exception itself, "Class: message"
173// (withLocation is false).
174func templateNotValid(err error, withLocation bool) string {
175	var te *template.Error
176	if !errors.As(err, &te) {
177		return err.Error()
178	}
179	if withLocation {
180		return "The template is not valid. " + te.Error()
181	}
182	var c exceptionClasser
183	if errors.As(err, &c) {
184		return "The template is not valid. " + c.ExceptionClass() + ": " + te.Raw
185	}
186	return te.Error()
187}
188
189func tokenSource(t template.Token) string {
190	if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
191		return be.Source
192	}
193	return t.String()
194}
195
196// formatStepName is ActionRunner.FormatStepName (ActionRunner.cs:447).
197func formatStepName(prefix, name string) string {
198	if name == "" {
199		return ""
200	}
201	return prefix + firstLine(name)
202}
203
204func (j *job) statusFuncs() workflow.StatusFuncs {
205	st := j.state.Status()
206	return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
207}
208
209// markCancelled records the cancellation of the job: job result Canceled and
210// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
211func (j *job) markCancelledLocked() {
212	if j.cancelled {
213		return
214	}
215	j.cancelled = true
216	c := resCanceled
217	j.result = &c
218	j.state.SetStatus("cancelled")
219}
220
221func (j *job) runStep(num int, st *workflow.Step) {
222	rep := j.cfg.Reporter
223	s := &stepRun{j: j, num: num, st: st}
224	s.cmds = newCommandManager(s, j.cfg.Debug)
225
226	// Environment of the step (StepsRunner.cs:108-139): job env, then the step's
227	// own, evaluated against the job env.
228	j.mu.Lock()
229	// github.action is the id of the step, set before the step's env is
230	// evaluated (StepsRunner.cs:118), so that the env and the if of a step see
231	// its own id (the dump step of the recordings reads __run_6, not __run_5).
232	j.state.SetGitHub("action", st.ID)
233	jobEnv := j.state.JobEnv()
234	j.mu.Unlock()
235	s.env = jobEnv.Clone()
236	pairs, envErr := j.evalMap(st.Env, jobEnv)
237	if envErr == nil {
238		for _, p := range pairs {
239			s.env.Set(p.Key, p.Value)
240		}
241	}
242	name := j.displayName(st, s.env)
243	rep.StepStarted(num, name, j.now())
244
245	complete := func(r stepResult) {
246		res := r
247		j.mu.Lock()
248		// Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
249		outcome := res
250		if s.outcome != nil {
251			outcome = *s.outcome
252		}
253		j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
254		if res == resFailed {
255			m := merge(j.result, resFailed)
256			j.result = &m
257			j.state.SetStatus(statusName(m))
258		}
259		j.mu.Unlock()
260		rep.StepCompleted(num, name, res.conclusion(), j.now())
261	}
262
263	if envErr != nil {
264		j.errorf(num, "%s", templateNotValid(envErr, true))
265		complete(resFailed)
266		return
267	}
268
269	// Cancellation seen before the step starts (StepsRunner.cs:188-197).
270	cancelledAtStart := false
271	select {
272	case <-j.cancel.Done():
273		cancelledAtStart = true
274		j.mu.Lock()
275		j.markCancelledLocked()
276		j.mu.Unlock()
277	default:
278	}
279
280	// Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
281	// evaluated and the step is skipped.
282	cond := func() (bool, error) {
283		j.mu.Lock()
284		defer j.mu.Unlock()
285		return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
286	}
287	if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
288		complete(resSkipped)
289		return
290	}
291	ok, err := cond()
292	switch {
293	case err != nil:
294		j.errorf(num, "%s", templateNotValid(err, false))
295		complete(resFailed)
296		return
297	case !ok:
298		complete(resSkipped)
299		return
300	}
301
302	// Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
303	// cancelled and the step is stopped unless its condition still holds
304	// (always()).
305	stepCancel := make(chan struct{})
306	stopWatch := make(chan struct{})
307	var watch sync.WaitGroup
308	if !cancelledAtStart {
309		watch.Add(1)
310		go func() {
311			defer watch.Done()
312			select {
313			case <-j.cancel.Done():
314			case <-stopWatch:
315				return
316			}
317			j.mu.Lock()
318			j.markCancelledLocked()
319			j.mu.Unlock()
320			keep := false
321			if j.cancel.Reason() != CancelShutdown {
322				keep, _ = cond()
323			}
324			if !keep {
325				close(stepCancel)
326			}
327		}()
328	}
329
330	r := j.execute(s, stepCancel)
331	close(stopWatch)
332	watch.Wait()
333
334	// Merge the result of the commands (StepsRunner.cs:347-351).
335	if s.cmdResult != nil {
336		m := merge(&r, *s.cmdResult)
337		r = m
338	}
339	// continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
340	if r == resFailed && st.ContinueOnError != nil {
341		coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
342		if cerr != nil {
343			j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
344			j.errorf(num, "%s", cerr.Error())
345		}
346		if coe && cerr == nil {
347			o := resFailed
348			s.outcome = &o
349			r = resSucceeded
350		}
351	}
352	complete(r)
353}
354
355// statusName is the job.status for a result (TaskResult.ToActionResult).
356func statusName(r stepResult) string {
357	switch r {
358	case resFailed:
359		return "failure"
360	case resCanceled:
361		return "cancelled"
362	}
363	return "success"
364}
365
366// notSupported is the failure of a step that uses a feature of a later phase.
367func notSupported(st *workflow.Step) string {
368	switch {
369	case st.Uses != nil:
370		return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
371	case st.TimeoutMinutes != nil:
372		return "not supported yet: timeout-minutes"
373	}
374	return ""
375}

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 4

Tags 11

↑↓ moveenter openesc close