step_run.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"context"
7	"sync"
8	"time"
9
10	"bnema.dev/ermite-actions/action"
11	"bnema.dev/ermite-actions/expr"
12	"bnema.dev/ermite-actions/template"
13	"bnema.dev/ermite-actions/workflow"
14)
15
16// qstep is a step of the job's queue (StepsRunner's JobSteps): a main step, the
17// pre or post stage of an action, or a setup step (pull or build an image).
18type qstep struct {
19	n     *node
20	stage stage
21	name  string
22	// cond, for a pre or post stage, replaces the step's own condition (the
23	// action's pre-if or post-if); setup steps use "success()".
24	cond string
25	// setup, when set, is the whole step: it runs instead of the node.
26	setup func(s *stepRun, cancel <-chan struct{}) stepResult
27	// ctxID is the context name of a runner step of the job (a Guid in "N"
28	// format), assigned before the step runs: its final result, skipped
29	// included, is registered in the steps context (JobExtension.cs:434-440,
30	// StepsRunner.CompleteStep, ExecutionContext.UpdateGlobalStepsContext:644).
31	// Empty for the setup steps a composite action runs (CompositeActionHandler.cs:121-123).
32	ctxID string
33}
34
35func (j *job) statusFuncs() workflow.StatusFuncs {
36	st := j.state.Status()
37	return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
38}
39
40// actionStatus is github.action_status of the steps of a composite action: the
41// result of the action so far (CompositeActionHandler.cs:246), the job's
42// cancellation included (:340). j.mu is held.
43func (s *stepRun) actionStatus() string {
44	if s.j.cancelled {
45		return "cancelled"
46	}
47	if s.result != nil {
48		return statusName(*s.result)
49	}
50	return "success"
51}
52
53// statusFuncs are the status functions of the step: success() and failure()
54// of an embedded main step read action_status, the others the job's status
55// (SuccessFunction.cs:28-45). j.mu is held.
56func (s *stepRun) statusFuncs() workflow.StatusFuncs {
57	if s.parent != nil && s.stage == stageMain {
58		a := s.parent.actionStatus()
59		return workflow.StatusFuncs{Success: a == "success", Failure: a == "failure", Cancelled: s.j.state.Status() == "cancelled"}
60	}
61	return s.j.statusFuncs()
62}
63
64// markCancelled records the cancellation of the job: job result Canceled and
65// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
66func (j *job) markCancelledLocked() {
67	if j.cancelled {
68		return
69	}
70	j.cancelled = true
71	c := resCanceled
72	j.result = &c
73	j.state.SetStatus("cancelled")
74}
75
76// evalCondString evaluates the pre-if or post-if of an action (an expression
77// without ${{ }}, evaluated as written, EvaluateStepIf on a bare condition).
78func (j *job) evalCondString(cond string, env *ordered, st workflow.StatusFuncs) (bool, error) {
79	sch, err := action.Schema()
80	if err != nil {
81		return false, err
82	}
83	tok := template.NewExpression(template.Origin{}, cond, sch.Info("step-if").WithContexts("vars", "needs"))
84	te := j.evalEnv(env)
85	funcs := map[string]expr.Func{}
86	for k, f := range te.Funcs {
87		funcs[k] = f
88	}
89	for k, f := range st.Funcs() {
90		funcs[k] = f
91	}
92	te.Funcs = funcs
93	v, err := template.EvaluateExpression(context.Background(), tok, te)
94	if err != nil {
95		return false, err
96	}
97	return expr.Truthy(v), nil
98}
99
100// stepEnvironment builds the environment of a step (StepsRunner.cs:108-139 and
101// CompositeActionHandler.cs:233-262): the job's, plus for an embedded step the
102// env of the steps around it, then its own env, evaluated against that.
103func (j *job) stepEnvironment(s *stepRun) (*ordered, error) {
104	j.mu.Lock()
105	env := j.state.JobEnv()
106	j.mu.Unlock()
107	s.overrides = newOrdered()
108	if p := s.parent; p != nil {
109		for _, k := range p.overrides.Keys() {
110			v, _ := p.env.Get(k)
111			env.Set(k, v)
112			s.overrides.Set(k, v)
113		}
114	}
115	pairs, err := j.evalStepEnv(s.st, env)
116	if err != nil {
117		return env, err
118	}
119	for _, p := range pairs {
120		env.Set(p.Key, p.Value)
121		s.overrides.Set(p.Key, p.Value)
122	}
123	return env, nil
124}
125
126// evalStepEnv evaluates the env of a step: a mapping, or the expression that
127// stands for one (a step of a composite action may write env: ${{ ... }}).
128func (j *job) evalStepEnv(st *workflow.Step, env *ordered) ([]Pair, error) {
129	if st.Env != nil || st.EnvExpression == nil {
130		return j.evalMap(st.Env, env)
131	}
132	v, err := template.Evaluate(context.Background(), st.EnvExpression, j.evalEnv(env))
133	if err != nil {
134		return nil, err
135	}
136	return pairsOf(v)
137}
138
139func pairsOf(v expr.Value) ([]Pair, error) {
140	d, ok := v.(*expr.Dictionary)
141	if !ok {
142		return nil, nil
143	}
144	var out []Pair
145	for _, p := range d.Pairs() {
146		if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
147			return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
148		}
149		out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
150	}
151	return out, nil
152}
153
154// runStep runs one step of the queue as a step of the job (its own number, its
155// own record in the log).
156func (j *job) runStep(num int, q qstep) {
157	rep := j.cfg.Reporter
158	s := &stepRun{j: j, num: num, stage: q.stage}
159	if q.n != nil {
160		s.st, s.n = q.n.st, q.n
161		// The state saved by the action is the one of its node, whatever the stage.
162		s.scope.stateKey = q.n.stateKey
163	}
164	s.cmds = newCommandManager(s, j.cfg.Debug)
165	name := q.name
166	if q.setup != nil {
167		s.name = name
168		s.ctxID = q.ctxID
169		rep.StepStarted(num, name, j.now())
170		r := j.runGuarded(s, q, true)
171		rep.StepCompleted(num, name, r.conclusion(), j.now())
172		return
173	}
174	if q.n == nil {
175		return
176	}
177	// github.action is the id of the step, set before the step's env is
178	// evaluated (StepsRunner.cs:118), so that the env and the if of a step see
179	// its own id (the dump step of the recordings reads __run_6, not __run_5).
180	j.mu.Lock()
181	j.state.SetGitHub("action", q.n.st.ID)
182	j.mu.Unlock()
183	env, envErr := j.stepEnvironment(s)
184	s.env = env
185	if name == "" {
186		name = j.displayName(q.n.st, s.env)
187		if q.stage == stagePre {
188			name = "Pre " + name
189		}
190	}
191	s.name = name
192	rep.StepStarted(num, name, j.now())
193	r := j.runGuarded(s, q, envErr == nil)
194	if envErr != nil {
195		j.errorf(num, "%s", templateNotValid(envErr, true))
196		r = resFailed
197		j.finishStep(s, r)
198	}
199	rep.StepCompleted(num, name, r.conclusion(), j.now())
200}
201
202// finishStep records the result of the step in the contexts
203// (ExecutionContext.UpdateGlobalStepsContext) and, for a step of the job, in
204// the result of the job.
205func (j *job) finishStep(s *stepRun, res stepResult) {
206	j.mu.Lock()
207	defer j.mu.Unlock()
208	st := s.st
209	outcome := res
210	if s.outcome != nil {
211		outcome = *s.outcome
212	}
213	// Pre and post stages of a step of the job have no context name, and
214	// generated ids are hidden: only the main stage and embedded steps count.
215	if s.n != nil && (s.parent != nil || s.stage == stageMain) && st != nil {
216		j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
217	}
218	if s.ctxID != "" {
219		j.state.SetStepResult(s.ctxID, string(outcome.conclusion()), string(res.conclusion()))
220	}
221	if s.parent == nil {
222		j.recordOutcome(s.num, outcome.conclusion())
223	}
224	if s.parent != nil {
225		if res == resFailed || res == resCanceled {
226			m := merge(s.parent.result, res)
227			s.parent.result = &m
228		}
229		return
230	}
231	if res == resFailed {
232		m := merge(j.result, resFailed)
233		j.result = &m
234		j.state.SetStatus(statusName(m))
235	}
236}
237
238// runGuarded is the body of a step between its start and its end: the
239// condition, the run with its cancellation watch, the commands' result,
240// continue-on-error. ok is false when the env of the step did not evaluate (the
241// error is reported by the caller).
242func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult {
243	num, st := s.num, s.st
244	complete := func(r stepResult) stepResult { j.finishStep(s, r); return r }
245	if !ok {
246		return resFailed
247	}
248	// Cancellation seen before the step starts (StepsRunner.cs:188-197).
249	cancelledAtStart := false
250	select {
251	case <-j.cancel.Done():
252		cancelledAtStart = true
253		j.mu.Lock()
254		j.markCancelledLocked()
255		j.mu.Unlock()
256	default:
257	}
258	cond := func() (bool, error) {
259		j.mu.Lock()
260		defer j.mu.Unlock()
261		switch {
262		case q.cond != "":
263			return j.evalCondString(q.cond, s.env, s.statusFuncs())
264		case q.setup != nil:
265			return j.evalCondString("success()", s.env, j.statusFuncs())
266		}
267		return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())
268	}
269	// On runner shutdown conditions are not evaluated and the step is skipped.
270	if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
271		return complete(resSkipped)
272	}
273	run, err := cond()
274	switch {
275	case err != nil:
276		j.errorf(num, "%s", templateNotValid(err, false))
277		return complete(resFailed)
278	case !run:
279		return complete(resSkipped)
280	}
281	// Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
282	// cancelled and the step is stopped unless its condition still holds
283	// (always()).
284	stepCancel := make(chan struct{})
285	var cancelOnce sync.Once
286	closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) }
287	stopWatch := make(chan struct{})
288	var watch sync.WaitGroup
289	if !cancelledAtStart {
290		watch.Add(1)
291		go func() {
292			defer watch.Done()
293			select {
294			case <-j.cancel.Done():
295			case <-stopWatch:
296				return
297			}
298			j.mu.Lock()
299			j.markCancelledLocked()
300			j.mu.Unlock()
301			keep := false
302			if j.cancel.Reason() != CancelShutdown {
303				keep, _ = cond()
304			}
305			if !keep {
306				closeCancel()
307			}
308		}()
309	}
310	// timeout-minutes (StepsRunner.RunStepAsync): the timer cancels the body like
311	// a job cancellation does, and does not cancel the job. An embedded step
312	// has its own timer, the step around it has its own.
313	var r stepResult
314	stopTimeout, minutes := func() (func(), int) { return func() {}, 0 }()
315	if q.setup == nil {
316		stopTimeout, minutes = j.armStepTimeout(s, closeCancel)
317		if minutes > 0 {
318			s.deadline = j.now().Add(time.Duration(minutes)*time.Minute + j.cfg.StepTimeoutSkew)
319		}
320		if p := s.parent; p != nil && !p.deadline.IsZero() {
321			// The inner step has the time that is left to the step around it, and
322			// no timeout of its own (the schema refuses one).
323			s.deadline = p.deadline
324			t := time.AfterFunc(time.Until(p.deadline), func() { s.timedOut.Store(true); closeCancel() })
325			stopTimeout = func() { t.Stop() }
326		}
327	}
328	if q.setup != nil {
329		r = q.setup(s, stepCancel)
330	} else {
331		r = j.execute(s, stepCancel)
332	}
333	stopTimeout()
334	close(stopWatch)
335	watch.Wait()
336	if minutes > 0 {
337		r = j.stepTimedOut(s, r, minutes)
338	}
339	if s.parent != nil && r == resCanceled && s.timedOut.Load() && !j.cancelRequested() {
340		// CompositeActionHandler.RunStepAsync (:497-506): an inner step stopped by
341		// the timeout it inherited fails with a message that names no step.
342		j.errorf(num, "The action has timed out.")
343		r = resFailed
344	}
345
346	// The step was marked failed before it ran (CompositeActionHandler.cs:302):
347	// nothing resets that when it goes well (RunStepAsync :481-527).
348	if s.envFailed && r == resSucceeded {
349		r = resFailed
350	}
351	// Merge the result of the commands (StepsRunner.cs:347-351).
352	if s.cmdResult != nil {
353		r = merge(&r, *s.cmdResult)
354	}
355	// continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
356	if r == resFailed && st != nil && st.ContinueOnError != nil {
357		coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
358		if cerr != nil {
359			j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
360			j.errorf(num, "%s", cerr.Error())
361		}
362		if coe && cerr == nil {
363			o := resFailed
364			s.outcome = &o
365			r = resSucceeded
366		}
367	}
368	return complete(r)
369}

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