v0.4.2 257b319

results.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"context"
7	"fmt"
8	"io"
9	"io/fs"
10	"os"
11	"strings"
12	"sync/atomic"
13	"time"
14
15	"bnema.dev/ermite-actions/template"
16)
17
18// What a step leaves besides its log: annotations, summary, outcome, saved
19// state. The pieces that the steps of phase F share (embedded steps of a
20// composite action, state of an action) are the fields marked below.
21
22// stepScope is what a step that runs inside another one (a step of a
23// composite action) shares with the step of the workflow that uses the action.
24// The zero value is a step of the workflow.
25type stepScope struct {
26	// issues, when set, is the collector of the outer step: the issues of an
27	// embedded step count against its limits (ExecutionContext.AddIssue,
28	// :838). Embedded is true then.
29	issues   *issueCollector
30	embedded bool
31	// stateKey names the intra-action state the step saves to (SaveState). Empty
32	// means the step's own number.
33	stateKey string
34}
35
36// AddIssue is ExecutionContext.AddIssue for the step.
37func (s *stepRun) AddIssue(is issue) { s.j.addIssueFor(s, is) }
38
39func (j *job) addIssueFor(s *stepRun, is issue) {
40	j.addIssue(s.num, s.scope.issues, s.scope.embedded, is)
41}
42
43// Workspace is github.workspace.
44func (s *stepRun) Workspace() string { return s.j.githubValue("workspace") }
45
46// HostPath is TranslateToHostPath of the job container.
47func (s *stepRun) HostPath(p string) string {
48	if c := s.j.stepContainer(); c != nil {
49		return c.translateToHost(p)
50	}
51	return p
52}
53
54func (j *job) githubValue(k string) string {
55	j.mu.Lock()
56	defer j.mu.Unlock()
57	return j.state.GitHub(k)
58}
59
60// AddMask is SecretMasker.AddValue.
61func (s *stepRun) AddMask(v string) { s.j.addMask(v) }
62
63// addMask registers values with the masker of the job, with their encodings
64// (SecretMasker.AddValue). It is the one way to add a secret after the job
65// started (add-mask, a stop-commands token, the token of an action archive): it
66// only adds, the masker is never replaced, so the text that other goroutines
67// mask meanwhile loses no value.
68func (j *job) addMask(values ...string) {
69	m := j.masker.Load()
70	for _, v := range values {
71		m.add(v)
72	}
73}
74
75// Matchers is the root of the problem matchers of the job.
76func (s *stepRun) Matchers() *matcherRoot { return s.j.matchers }
77
78// maxMatcherFile bounds a problem matcher file.
79const maxMatcherFile = 1 << 20
80
81// ReadMatcherFile reads a problem matcher file.
82func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {
83	// With a job container the path came from a command of the container: it
84	// is read only when it is a file of a directory the container shares with
85	// the host, never the host's own file of the same name.
86	if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) {
87		return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
88	}
89	f, err := os.Open(path)
90	if err != nil {
91		return nil, err
92	}
93	defer func() { _ = f.Close() }()
94	b, err := io.ReadAll(io.LimitReader(f, maxMatcherFile+1))
95	if err != nil {
96		return nil, err
97	}
98	if len(b) > maxMatcherFile {
99		return nil, ghErrorf("The problem matcher file is larger than %d bytes", maxMatcherFile)
100	}
101	return b, nil
102}
103
104func (j *job) getenv(k string) string {
105	if j.cfg.Getenv != nil {
106		return j.cfg.Getenv(k)
107	}
108	return os.Getenv(k)
109}
110
111// SaveState is the state of an action step (save-state and GITHUB_STATE): kept
112// per step so that the post step of the action can read it as STATE_<name>
113// (ActionCommandManager.cs:384-398). A step that is not an action only fills
114// it; the runner does the same.
115func (s *stepRun) SaveState(name, value string) { s.j.saveState(s.stateKey(), name, value) }
116
117func (s *stepRun) stateKey() string {
118	if s.scope.stateKey != "" {
119		return s.scope.stateKey
120	}
121	return fmt.Sprint(s.num)
122}
123
124// intraState is IntraActionState: a Dictionary<string, string> with an
125// OrdinalIgnoreCase comparer. A name that is already there keeps the spelling
126// it was first saved with and takes the new value (the indexer of Dictionary
127// replaces the value, not the key), and the entries enumerate in the order
128// they were added: ActionRunner.cs:255-258 sets STATE_<key> from that
129// enumeration, so the spelling of the first save-state is the one the action
130// sees.
131type intraState struct {
132	keys []string // spelling and order of first insertion
133	vals map[string]string
134}
135
136func (st *intraState) set(name, value string) {
137	if st.vals == nil {
138		st.vals = map[string]string{}
139	}
140	lo := strings.ToLower(name)
141	if _, ok := st.vals[lo]; !ok {
142		st.keys = append(st.keys, name)
143	}
144	st.vals[lo] = value
145}
146
147// Pair is one saved name (as first spelled) and its value.
148func (st *intraState) pairs() []Pair {
149	out := make([]Pair, 0, len(st.keys))
150	for _, k := range st.keys {
151		out = append(out, Pair{k, st.vals[strings.ToLower(k)]})
152	}
153	return out
154}
155
156func (j *job) saveState(key, name, value string) {
157	j.issuesMu.Lock()
158	defer j.issuesMu.Unlock()
159	if j.states == nil {
160		j.states = map[string]*intraState{}
161	}
162	st := j.states[key]
163	if st == nil {
164		st = &intraState{}
165		j.states[key] = st
166	}
167	st.set(name, value)
168}
169
170// intraActionState returns the state saved under key (the number of the step,
171// or the stateKey of its scope) as the runner enumerates it: names as first
172// spelled, in the order they were first saved. Phase F sets STATE_<name> from
173// it for the pre, main and post stages of the action.
174func (j *job) intraActionState(key string) []Pair {
175	j.issuesMu.Lock()
176	defer j.issuesMu.Unlock()
177	if st := j.states[key]; st != nil {
178		return st.pairs()
179	}
180	return nil
181}
182
183// Summary receives GITHUB_STEP_SUMMARY after the step (see processStepSummary).
184//
185// The server keeps one summary per step of the workflow. The steps inside a
186// composite action each have a summary file in the runner (uploaded under the
187// id of the embedded step); here they are concatenated, in order, under the
188// step that uses the action.
189func (s *stepRun) Summary(markdown string) {
190	j := s.j
191	j.issuesMu.Lock()
192	defer j.issuesMu.Unlock()
193	if j.summaries == nil {
194		j.summaries = map[int]string{}
195	}
196	if s.scope.embedded {
197		markdown = j.summaries[s.num] + markdown
198		if len(markdown) > stepSummaryLimit {
199			return // over the limit of the contract: the first ones stay
200		}
201	}
202	j.summaries[s.num] = markdown
203}
204
205// MaskText masks text with the secrets known now.
206func (s *stepRun) MaskText(t string) string { return s.j.mask(t) }
207
208// summarySink is implemented by the Sink of a step that keeps summaries.
209type summarySink interface{ Summary(markdown string) }
210
211// recordOutcome keeps steps.<id>.outcome of a step for StepResults.
212func (j *job) recordOutcome(num int, c Conclusion) {
213	j.issuesMu.Lock()
214	defer j.issuesMu.Unlock()
215	if j.outcomes == nil {
216		j.outcomes = map[int]Conclusion{}
217	}
218	j.outcomes[num] = c
219}
220
221// flushStepResults hands the results of step n to the reporter, before the step
222// completes. Called with the serialisation of the reporter not held.
223func (j *job) flushStepResults(next Reporter, n int, c Conclusion) {
224	rr, ok := next.(StepResultReporter)
225	if !ok {
226		return
227	}
228	j.issuesMu.Lock()
229	outcome, has := j.outcomes[n]
230	summary, hasSummary := j.summaries[n]
231	delete(j.summaries, n)
232	coll := j.collectors[n]
233	j.issuesMu.Unlock()
234	if !has {
235		outcome = c
236	}
237	r := StepResults{Outcome: outcome}
238	if coll != nil && j.cfg.SendAnnotations {
239		r.Annotations = coll.result()
240	}
241	if hasSummary && j.cfg.SendSummaries {
242		r.Summary, r.HasSummary = summary, true
243	}
244	rr.StepResults(n, r)
245}
246
247// logCanceled logs the cancellation of the process of a step
248// (StepsRunner.cs:333-337 logs the exception of a cancelled step), unless the
249// step timed out: then StepsRunner logs the timeout message instead.
250func (s *stepRun) logCanceled() {
251	if s.timedOut.Load() && !s.j.cancelRequested() {
252		return
253	}
254	s.j.errorf(s.num, "The operation was canceled.")
255}
256
257func (j *job) cancelRequested() bool {
258	select {
259	case <-j.cancel.Done():
260		return true
261	default:
262		return false
263	}
264}
265
266// ---- timeout-minutes
267
268// timedOutFlag is the marker of a step that reached its timeout.
269type timedOutFlag = atomic.Bool
270
271// stepTimeoutMessage is StepsRunner.cs:328.
272func stepTimeoutMessage(name string, minutes int) string {
273	return fmt.Sprintf("The action '%s' has timed out after %d minutes.", name, minutes)
274}
275
276// ---- step summary
277
278// summaryContent is what CreateStepSummaryCommand uploads: the lines of the
279// file, each masked, each ended by a line feed (StreamReader.ReadLine and
280// StreamWriter.WriteLine; ProcessCommand, FileCommandManager.cs:253-261).
281func summaryContent(text string, mask func(string) string) string {
282	var sb strings.Builder
283	for _, l := range splitLines(text) {
284		sb.WriteString(mask(l))
285		sb.WriteByte('\n')
286	}
287	return sb.String()
288}
289
290// maxStepTimeoutMinutes bounds the timeout so that the duration fits.
291const maxStepTimeoutMinutes = 1 << 24
292
293// stepTimeout is PipelineTemplateEvaluator.EvaluateStepTimeout and
294// ConvertToStepTimeout: the number is truncated to an int; zero, negative and
295// absent values mean no timeout. An expression that fails logs the runner's
296// two errors and the step runs without timeout (StepsRunner.cs:266-273).
297func (j *job) stepTimeout(s *stepRun) int {
298	tok := s.st.TimeoutMinutes
299	if tok == nil {
300		return 0
301	}
302	j.mu.Lock()
303	f, err := template.EvaluateNumber(context.Background(), tok, j.evalEnv(s.env))
304	j.mu.Unlock()
305	if err != nil {
306		j.errorf(s.num, "An error occurred when attempting to determine the step timeout.")
307		j.errorf(s.num, "%s", templateNotValid(err, true))
308		return 0
309	}
310	switch {
311	case f != f || f < 1:
312		return 0
313	case f > maxStepTimeoutMinutes:
314		return maxStepTimeoutMinutes
315	}
316	return int(f)
317}
318
319// ---- hook points for steps that contain steps (phase F)
320//
321// A step of the workflow is run by runStep; a composite action runs its inner
322// steps itself. What an inner step shares with the step that contains it is
323// named by a stepScope, and what its lifecycle needs from the commands is:
324//
325//   - embeddedScope(outer, stateKey): the scope of an inner step. Its issues
326//     go to the collector of the outer step (counted against its limits when
327//     the outer step completes) and its summary is appended to the outer
328//     step's; its save-state goes to the state of stateKey.
329//   - newOutputManager(s, cancel): one per process of a step (inner or not),
330//     with the cancellation channel the process gets; call dispose when the
331//     process is over.
332//   - armStepTimeout / stepTimedOut: the timeout of a step, inner or not,
333//     around the run of its body (see runStep).
334//   - addMask: add a secret.
335
336// embeddedScope is the scope of a step inside the step outer.
337func (j *job) embeddedScope(outer *stepRun, stateKey string) stepScope {
338	return stepScope{issues: j.collector(outer.num), embedded: true, stateKey: stateKey}
339}
340
341// armStepTimeout starts the timeout-minutes timer of the step (StepsRunner.
342// RunStepAsync, :280-296). When it fires, the step is marked timed out and
343// cancel is called (the same cancellation as a job cancel: SIGINT, SIGTERM,
344// SIGKILL). It returns the function that stops the timer and the number of
345// minutes, 0 for none.
346func (j *job) armStepTimeout(s *stepRun, cancel func()) (stop func(), minutes int) {
347	minutes = j.stepTimeout(s)
348	if minutes <= 0 {
349		return func() {}, 0
350	}
351	t := time.AfterFunc(time.Duration(minutes)*time.Minute+j.cfg.StepTimeoutSkew, func() {
352		s.timedOut.Store(true)
353		cancel()
354	})
355	return func() { t.Stop() }, minutes
356}
357
358// stepTimedOut turns the result of a step whose own timer fired (and not the
359// job's cancellation) into a failure with the runner's message
360// (StepsRunner.cs:323-330). Other results pass through.
361func (j *job) stepTimedOut(s *stepRun, r stepResult, minutes int) stepResult {
362	if r == resCanceled && s.timedOut.Load() && !j.cancelRequested() {
363		j.errorf(s.num, "%s", stepTimeoutMessage(s.name, minutes))
364		return resFailed
365	}
366	return r
367}

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