v0.2.0 43088fd

job.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"os"
10	"path/filepath"
11	"slices"
12	"strings"
13	"sync"
14	"sync/atomic"
15	"syscall"
16	"time"
17
18	"bnema.dev/ermite-actions/expr"
19	"bnema.dev/ermite-actions/template"
20	"bnema.dev/ermite-actions/workflow"
21	"github.com/bnema/zerowrap"
22	"github.com/google/uuid"
23)
24
25// Names of the implicit steps (JobExtension.cs:70 and :15).
26const (
27	SetUpJobName    = "Set up job"
28	CompleteJobName = "Complete job"
29)
30
31// DefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
32const DefaultTimeoutMinutes = 360
33
34// MaxOutputBytes is the limit of ermite on the job outputs, names and values
35// together (1 MiB).
36const MaxOutputBytes = 1 << 20
37
38// maxIssueMessageLength is ExecutionContext._maxIssueMessageLength.
39const maxIssueMessageLength = 4096
40
41// Config describes the job to run.
42type Config struct {
43	// WorkflowPath and WorkflowYAML are the workflow file, which the executor
44	// parses itself; JobKey selects the job.
45	WorkflowPath string
46	WorkflowYAML []byte
47	JobKey       string
48	// JobName is the display name of the job, for "Complete job name: ...".
49	// Empty means the job key.
50	JobName string
51	// ContextsJSON and MatrixJSON are the fields of the job message.
52	ContextsJSON string
53	MatrixJSON   string
54	// Repo is the repository name used when the github context lacks one.
55	Repo string
56	// TimeoutMinutes is the job timeout; 0 means DefaultTimeoutMinutes. It is
57	// used for the message and, without Deadline, for the timer.
58	TimeoutMinutes int
59	// Deadline is the time left of the job timeout when Run is called, when the
60	// timeout started counting earlier (at the receipt of the job by the host);
61	// 0 means TimeoutMinutes from now.
62	Deadline time.Duration
63
64	Runner        RunnerInfo
65	RunnerVersion string
66	// Layout overrides the directory layout (tests); nil means the hosted layout
67	// under Home.
68	Layout *Layout
69	// Home is the home directory of the user of the steps; empty means
70	// /home/runner.
71	Home string
72	// ToolCache is RUNNER_TOOL_CACHE; empty means DefaultToolCache. The official
73	// runner reads it from its own environment (HostContext.cs:411).
74	ToolCache string
75	// BaseEnv is the environment steps inherit; nil means os.Environ().
76	BaseEnv []string
77	// Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
78	Debug bool
79	// Actions configures the actions of the job (resolution, downloads, Node.js,
80	// docker).
81	Actions ActionsConfig
82	// SendAnnotations and SendSummaries say that the server keeps annotations
83	// and step summaries (features ci.annotations and ci.summaries): the
84	// Reporter then gets them with StepResults.
85	SendAnnotations, SendSummaries bool
86	// Getenv reads the environment of the executor (the matcher timeout); nil
87	// means os.Getenv.
88	Getenv func(string) string
89	// StepTimeoutSkew is added to every step timeout (tests use a negative
90	// value to run a timeout of one minute in milliseconds).
91	StepTimeoutSkew time.Duration
92
93	Reporter Reporter
94	// Log receives the runner's own diagnostics (not the job log); the zero value
95	// discards them.
96	Log zerowrap.Logger
97
98	// Credential, when set, is the user the steps run as.
99	Credential *syscall.Credential
100	// Cancellation timings and test hooks, see procSpec.
101	TermAfter, KillAfter, WaitDelay time.Duration
102	Signal                          func(pid int, sig syscall.Signal)
103	// Now returns the current time; nil means time.Now.
104	Now func() time.Time
105}
106
107type stepResult int
108
109// Values are ordered like TaskResult so that merge picks the worst
110// (TaskResultUtil.MergeTaskResults).
111const (
112	resSucceeded stepResult = iota
113	resFailed
114	resCanceled
115	resSkipped
116)
117
118func (r stepResult) conclusion() Conclusion {
119	switch r {
120	case resFailed:
121		return ConclusionFailure
122	case resCanceled:
123		return ConclusionCancelled
124	case resSkipped:
125		return ConclusionSkipped
126	}
127	return ConclusionSuccess
128}
129
130// merge is TaskResultUtil.MergeTaskResults (Runner.Common/Util/TaskResultUtil.cs:40).
131func merge(cur *stepResult, coming stepResult) stepResult {
132	if cur == nil {
133		return coming
134	}
135	if *cur > resFailed {
136		return *cur
137	}
138	if coming >= *cur {
139		return coming
140	}
141	return *cur
142}
143
144type job struct {
145	cfg    Config
146	cancel *Canceller
147	wf     *workflow.Workflow
148	jb     *workflow.Job
149	fc     *FileCommands
150	// tempRoot is RUNNER_TEMP, opened before the first step (see FileCommands).
151	tempRoot *os.Root
152	trackID  string
153
154	mu          sync.Mutex // serialises the Reporter and guards state and result
155	state       *State
156	result      *stepResult // nil until a step fails or the job is cancelled (Global result)
157	cancelled   bool
158	runDefaults struct{ shell, workingDirectory string }
159	baseEnv     []string
160	lookupPath  string
161	before      map[string]bool
162	// masker hides the secrets in everything reported; it is set once the
163	// contexts are built, before any step runs.
164	masker atomic.Pointer[masker]
165	// actions holds what the pre-job phase loaded (see prepareActions).
166	actions *actionState
167	// postStack is the post steps registered so far, last registered last.
168	postStack []qstep
169	// upgradedToNode24 are the actions that asked for node20 and got node24.
170	upgradedToNode24 []string
171	jobCtx           context.Context
172	// cs is the containers of the job (nil container and no services: none).
173	cs *containerSet
174
175	// Results of the commands (see issues.go, matchers.go, results.go).
176	matchers   *matcherRoot
177	issuesMu   sync.Mutex
178	collectors map[int]*issueCollector // by step number
179	outcomes   map[int]Conclusion
180	summaries  map[int]string
181	logLines   map[int]int            // lines written to the log of each step, under mu
182	states     map[string]*intraState // intra-action state, see saveState
183}
184
185func (j *job) mask(s string) string { return j.masker.Load().mask(s) }
186
187func (j *job) now() time.Time {
188	if j.cfg.Now != nil {
189		return j.cfg.Now()
190	}
191	return time.Now()
192}
193
194// Run executes one job and reports to cfg.Reporter. It always ends with a
195// JobCompleted call and returns the same Result. cancel may be nil.
196func Run(cancel *Canceller, cfg Config) Result {
197	if cancel == nil {
198		cancel = NewCanceller()
199	}
200	j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString(), matchers: &matcherRoot{}}
201	j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}
202	defer func() {
203		if j.fc != nil {
204			_ = j.fc.Close()
205		}
206		if j.tempRoot != nil {
207			_ = j.tempRoot.Close()
208		}
209	}()
210	res := j.run()
211	res.Error = j.mask(res.Error)
212	j.cfg.Reporter.JobCompleted(res)
213	return res
214}
215
216func (j *job) run() Result {
217	// Whatever happens below (a panic, a shutdown that skips the post steps),
218	// the containers, the network and the credentials directories are removed.
219	defer j.fallbackCleanup()
220	limit := j.cfg.Deadline
221	if limit <= 0 {
222		limit = time.Duration(j.timeoutMinutes()) * time.Minute
223	}
224	timer := time.AfterFunc(limit, func() { j.cancel.Cancel(CancelTimeout) })
225	defer timer.Stop()
226
227	// ---- Set up job (JobExtension.InitializeJob) ----
228	rep := j.cfg.Reporter
229	rep.StepStarted(1, SetUpJobName, j.now())
230	var cancelCtx context.CancelFunc
231	j.jobCtx, cancelCtx = context.WithCancel(context.Background())
232	defer cancelCtx()
233	go func() {
234		select {
235		case <-j.cancel.Done():
236			cancelCtx()
237		case <-j.jobCtx.Done():
238		}
239	}()
240	steps, err := j.setUp()
241	if err != nil {
242		j.errorf(1, "%s", err.Error())
243		rep.StepCompleted(1, SetUpJobName, ConclusionFailure, j.now())
244		// The job ends here, without "Complete job" (JobRunner.cs:107).
245		return Result{Conclusion: ConclusionFailure, Error: err.Error()}
246	}
247	rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
248
249	// ---- the steps (StepsRunner) ----
250	// The queue is the pre-job steps then the steps of the job; the post steps
251	// registered while they run follow, last registered first
252	// (StepsRunner.cs:62-75).
253	num := 2
254	for _, q := range steps {
255		j.runStep(num, q)
256		num++
257	}
258	for {
259		j.mu.Lock()
260		if len(j.postStack) == 0 {
261			j.mu.Unlock()
262			break
263		}
264		q := j.postStack[len(j.postStack)-1]
265		j.postStack = j.postStack[:len(j.postStack)-1]
266		j.mu.Unlock()
267		j.runStep(num, q)
268		num++
269	}
270
271	// ---- Complete job (JobExtension.FinalizeJob) ----
272	outputs := j.completeJob(num)
273
274	return j.finish(outputs)
275}
276
277func (j *job) finish(outputs map[string]string) Result {
278	res := Result{Outputs: outputs}
279	j.mu.Lock()
280	defer j.mu.Unlock()
281	r := resSucceeded
282	if j.result != nil {
283		r = *j.result
284	}
285	switch {
286	case j.cancel.Reason() == CancelTimeout && j.cancelled:
287		res.Conclusion = ConclusionTimedOut
288		res.Error = fmt.Sprintf("The job has exceeded the maximum execution time of %d minutes.", j.timeoutMinutes())
289	case r == resCanceled:
290		res.Conclusion = ConclusionCancelled
291		res.RunnerShutdown = j.cancel.Reason() == CancelShutdown
292	case r == resFailed:
293		res.Conclusion = ConclusionFailure
294	default:
295		res.Conclusion = ConclusionSuccess
296	}
297	return res
298}
299
300func (j *job) timeoutMinutes() int {
301	if j.cfg.TimeoutMinutes > 0 {
302		return j.cfg.TimeoutMinutes
303	}
304	return DefaultTimeoutMinutes
305}
306
307// ---------------------------------------------------------------- logging
308
309// write sends one log line to the Reporter, in order. It returns the 1-based
310// number of the line in the log of the step num, what the runner calls
311// logFileLineNumber (_logger.TotalLines + 1 in ExecutionContext.Write). The
312// steps inside a composite action write to the log of the step that uses it,
313// so they count in the same sequence.
314func (j *job) write(num int, line string) int {
315	j.mu.Lock()
316	defer j.mu.Unlock()
317	if j.logLines == nil {
318		j.logLines = map[int]int{}
319	}
320	n := j.logLines[num] + 1
321	// A line with a line feed in it is several lines of the log (Logging.cs:
322	// 90-101); the host splits the text the same way.
323	j.logLines[num] += 1 + strings.Count(line, "\n")
324	j.cfg.Reporter.Log(num, j.now(), line)
325	return n
326}
327
328// out writes a message that may hold several lines (ExecutionContext.Output).
329func (j *job) out(num int, text string) {
330	for _, l := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
331		j.write(num, l)
332	}
333}
334
335// tagged writes tag+message; only the first line carries the tag. The tags of
336// issues (##[error], ##[warning], ##[notice]) go through AddIssue: they are
337// logged and become annotations, like the runner's IExecutionContext.Error
338// and Warning.
339func (j *job) tagged(num int, tag, msg string) {
340	if level, ok := levelOfTag(tag); ok {
341		j.addIssue(num, nil, false, issue{Level: level, Message: msg})
342		return
343	}
344	j.logTagged(num, tag, j.mask(msg))
345}
346
347func (j *job) errorf(num int, format string, a ...any) {
348	j.tagged(num, "##[error]", fmt.Sprintf(format, a...))
349}
350
351func (j *job) debug(num int, msg string) {
352	if j.cfg.Debug {
353		for _, l := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") {
354			j.write(num, "##[debug]"+l)
355		}
356	}
357}
358
359// ---------------------------------------------------------------- set up
360
361// evalEnv is the environment for expressions of the job and step tokens.
362func (j *job) evalEnv(env *ordered) template.Env {
363	return template.Env{
364		Contexts: j.state.Values(env),
365		Funcs:    map[string]expr.Func{"hashFiles": j.hashFilesFunc()},
366	}
367}
368
369func (j *job) evalString(tok template.Token, env *ordered) (string, error) {
370	if tok == nil {
371		return "", nil
372	}
373	return template.EvaluateString(context.Background(), tok, j.evalEnv(env))
374}
375
376// evalMap evaluates a mapping of strings (EvaluateStepEnvironment): values
377// are converted like GitHub converts them (null is the empty string).
378func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
379	if m == nil {
380		return nil, nil
381	}
382	v, err := template.Evaluate(context.Background(), m, j.evalEnv(env))
383	if err != nil {
384		return nil, err
385	}
386	d, ok := v.(*expr.Dictionary)
387	if !ok {
388		return nil, nil
389	}
390	var out []Pair
391	for _, p := range d.Pairs() {
392		if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
393			return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
394		}
395		out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
396	}
397	return out, nil
398}
399
400// setUp parses the workflow and prepares the job: JobExtension.InitializeJob
401// (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
402// log lines belong to step 1.
403func (j *job) setUp() ([]qstep, error) {
404	cfg := j.cfg
405	j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
406	// Only a self-hosted runner prints its name and machine (JobExtension.cs:
407	// 88-98: the runner is identified by its OAuth client id); the recordings
408	// of github-hosted runners have neither line. The runner group name
409	// (system.runnerGroupName) is not known to ermite.
410	if !cfg.Runner.githubHosted() {
411		if cfg.Runner.Name != "" {
412			j.out(1, fmt.Sprintf("Runner name: '%s'", cfg.Runner.Name))
413		}
414		if host, err := os.Hostname(); err == nil {
415			j.out(1, fmt.Sprintf("Machine name: '%s'", host))
416		}
417	}
418
419	wf, errs := workflow.Parse(cfg.WorkflowPath, cfg.WorkflowYAML)
420	if len(errs) > 0 {
421		var msgs []string
422		for _, e := range errs {
423			msgs = append(msgs, e.Error())
424		}
425		return nil, ghErrorf("The workflow is not valid. %s", strings.Join(msgs, "\n"))
426	}
427	jb := wf.Job(cfg.JobKey)
428	if jb == nil {
429		return nil, ghErrorf("The workflow has no job '%s'.", cfg.JobKey)
430	}
431	j.wf, j.jb = wf, jb
432
433	state, layout, err := NewState(cfg.ContextsJSON, cfg.MatrixJSON, cfg.JobKey, cfg.Runner, cfg.Repo)
434	if err != nil {
435		return nil, err
436	}
437	switch {
438	case cfg.Layout != nil:
439		state.WithLayout(*cfg.Layout)
440		layout = *cfg.Layout
441	case (cfg.Home != "" && cfg.Home != layout.Home) || cfg.ToolCache != "":
442		home := cfg.Home
443		if home == "" {
444			home = layout.Home
445		}
446		layout = NewLayout(home, filepath.Base(layout.Workspace), cfg.ToolCache)
447		state.WithLayout(layout)
448	}
449	j.state = state
450	j.masker.Store(newMasker(state.Secrets()...))
451
452	// Features that arrive in later phases fail the job with a clear message.
453	switch {
454	case jb.IsReusable():
455		return nil, errors.New("not supported yet: reusable workflows (jobs.<id>.uses)")
456	}
457
458	j.printTokenInfo()
459
460	// Directories (TempDirectoryManager, PipelineDirectoryManager).
461	j.out(1, "Prepare workflow directory")
462	for _, d := range []string{layout.Temp, layout.Workspace, layout.ToolCache} {
463		if err := os.MkdirAll(d, 0o755); err != nil {
464			return nil, fmt.Errorf("create %s: %w", d, err)
465		}
466	}
467	clearDir(layout.Temp)
468	if err := os.MkdirAll(layout.Temp, 0o755); err != nil {
469		return nil, err
470	}
471	fc, err := NewFileCommands(filepath.Join(layout.Temp, "_runner_file_commands"))
472	if err != nil {
473		return nil, err
474	}
475	if c := cfg.Credential; c != nil {
476		fc.SetOwner(int(c.Uid), int(c.Gid))
477	}
478	j.fc = fc
479	// Everything the executor writes below the temp directory later goes through
480	// this root (see FileCommands): the user of the steps owns the directory.
481	if j.tempRoot, err = os.OpenRoot(layout.Temp); err != nil {
482		return nil, err
483	}
484	for _, d := range []string{layout.WorkRoot, layout.RunnerWorkspace, layout.Workspace, layout.Temp, fc.dir} {
485		j.own(d)
486	}
487
488	j.baseEnv = cfg.BaseEnv
489	if j.baseEnv == nil {
490		j.baseEnv = os.Environ()
491	}
492	if !hasEnv(j.baseEnv, "HOME") {
493		j.baseEnv = append(append([]string(nil), j.baseEnv...), "HOME="+layout.Home)
494	}
495	j.lookupPath = envValue(j.baseEnv, "PATH")
496
497	// Job-level environment: workflow env, then job env, each evaluated with an
498	// empty env context (JobExtension.cs:38-49, ExecutionContext.cs:1048).
499	for _, m := range []*template.Mapping{wf.Env, jb.Env} {
500		pairs, err := j.evalMap(m, newOrdered())
501		if err != nil {
502			return nil, err
503		}
504		for _, p := range pairs {
505			j.state.SetEnv(p.Key, p.Value)
506		}
507	}
508
509	// defaults.run: a job-level "run" replaces the workflow's whole (JobExtension.cs:78-96).
510	for _, d := range []*workflow.Defaults{wf.Defaults, jb.Defaults} {
511		if d == nil || d.Run == nil {
512			continue
513		}
514		shell, err := j.evalString(d.Run.Shell, newOrdered())
515		if err != nil {
516			return nil, err
517		}
518		wd, err := j.evalString(d.Run.WorkingDirectory, newOrdered())
519		if err != nil {
520			return nil, err
521		}
522		j.runDefaults.shell, j.runDefaults.workingDirectory = shell, wd
523	}
524
525	// JobExtension.cs:250-275: the job container and the services are
526	// evaluated after the job environment, before the actions are prepared.
527	j.debug(1, "Evaluating job container")
528	j.debug(1, "Evaluating job service containers")
529	jc, svcs, err := j.evalContainers()
530	if err != nil {
531		return nil, err
532	}
533	if jc != nil || len(svcs) > 0 {
534		j.cs = &containerSet{job: jc, services: svcs}
535	}
536
537	j.out(1, "Prepare all required actions")
538	queue, err := j.prepareJob()
539	if err != nil {
540		return nil, err
541	}
542	// JobExtension.cs:418-422: the display name of the job, which the server
543	// computes there (message.JobDisplayName). ermite's Job message has no such
544	// field, so the executor evaluates it (see jobDisplayName).
545	name := cfg.JobName
546	if name == "" {
547		name = j.jobDisplayName()
548	}
549	if strings.TrimSpace(name) != "" {
550		j.out(1, "Complete job name: "+name)
551	}
552
553	// Process tracking for the orphan cleanup of "Complete job".
554	j.before = map[string]bool{}
555	for _, p := range snapshotProcesses() {
556		j.before[p.key()] = true
557	}
558	return queue, nil
559}
560
561// own gives a file or directory created by the executor to the user the steps
562// run as, so that scripts can write to RUNNER_TEMP, the workspace and the file
563// command files. Best effort: without Credential nothing is done.
564func (j *job) own(path string) {
565	if c := j.cfg.Credential; c != nil {
566		_ = os.Lchown(path, int(c.Uid), int(c.Gid))
567	}
568}
569
570func clearDir(dir string) {
571	ents, err := os.ReadDir(dir)
572	if err != nil {
573		return
574	}
575	for _, e := range ents {
576		_ = os.RemoveAll(filepath.Join(dir, e.Name()))
577	}
578}
579
580func hasEnv(env []string, key string) bool {
581	for _, kv := range env {
582		if strings.HasPrefix(kv, key+"=") {
583			return true
584		}
585	}
586	return false
587}
588
589func envValue(env []string, key string) string {
590	v := ""
591	for _, kv := range env {
592		if k, val, ok := strings.Cut(kv, "="); ok && k == key {
593			v = val
594		}
595	}
596	return v
597}
598
599// ---------------------------------------------------------------- complete
600
601// completeJob is JobExtension.FinalizeJob (JobExtension.cs:9-190): job outputs
602// and the cleanup of orphan processes.
603func (j *job) completeJob(num int) map[string]string {
604	rep := j.cfg.Reporter
605	rep.StepStarted(num, CompleteJobName, j.now())
606	result := resSucceeded
607	outputs := map[string]string{}
608
609	if j.jb.Outputs != nil {
610		// The runner of the official service prints nothing here: the recordings
611		// of github.com show only "Cleaning up orphan processes" in "Complete
612		// job", although the jobs have outputs (the log lines of
613		// JobExtension.cs:48,68 belong to the legacy message that carries
614		// JobOutputs).
615		j.mu.Lock()
616		env := j.state.JobEnv()
617		j.mu.Unlock()
618		pairs, err := j.evalMap(j.jb.Outputs, env)
619		if err != nil {
620			result = resFailed
621			j.errorf(num, "Fail to evaluate job outputs")
622			j.errorf(num, "%s", err.Error())
623			j.mu.Lock()
624			m := merge(j.result, resFailed)
625			j.result = &m
626			j.mu.Unlock()
627		}
628		total := 0
629		for _, p := range pairs {
630			if p.Value == "" {
631				j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))
632				continue
633			}
634			// An output that holds a secret is dropped (JobExtension.cs:819-824).
635			if j.masker.Load().has(p.Value) {
636				j.tagged(num, "##[warning]", fmt.Sprintf("Skip output '%s' since it may contain secret.", p.Key))
637				continue
638			}
639			// ermite refuses outputs above 1 MiB in total (runner.proto,
640			// CompleteJobRequest.outputs), and they would not fit a frame of the
641			// stream either: the job fails without outputs.
642			if total += len(p.Key) + len(p.Value); total > MaxOutputBytes {
643				result = resFailed
644				j.errorf(num, "Fail to evaluate job outputs")
645				j.errorf(num, "The job outputs are larger than 1 MiB in total.")
646				j.mu.Lock()
647				m := merge(j.result, resFailed)
648				j.result = &m
649				j.mu.Unlock()
650				outputs = map[string]string{}
651				break
652			}
653			outputs[p.Key] = p.Value
654		}
655	}
656
657	j.out(num, "Cleaning up orphan processes")
658	killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) })
659
660	// The actions that asked for node20 and got node24 (JobExtension.cs:1043-1049).
661	j.mu.Lock()
662	up := slices.SortedFunc(slices.Values(j.upgradedToNode24), func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
663	j.mu.Unlock()
664	if len(up) > 0 {
665		j.tagged(num, "##[warning]", "Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: "+
666			strings.Join(up, ", ")+". For more information see: "+node20DeprecationURL)
667	}
668
669	rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
670	return outputs
671}
672
673// Defaults of the lines "Set up job" prints between the runner version and
674// "Prepare workflow directory" (JobExtension.cs:150-182). The server gives
675// GitHub's runner these as the variables system.github.token.permissions and
676// actions_cache_mode and the github context key secret_source; ermite's Job
677// message carries none of them, so the values recorded on github.com for a
678// push to a public repository are used. Every one of the 221 recorded jobs
679// has exactly these lines.
680const (
681	defaultSecretSource = "Actions"
682	defaultCacheMode    = "write"
683)
684
685// printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source
686// and the cache mode.
687//
688// GitHub's runner prints what the server sends as
689// system.github.token.permissions (JobExtension.cs:154-166); ermite's Job
690// message does not carry it, so the group is worked out from the workflow
691// with the rules recorded on github.com (tokenPermissionLines). It is always
692// printed: Metadata is listed even for "permissions: {}".
693func (j *job) printTokenInfo() {
694	j.out(1, "##[group]GITHUB_TOKEN Permissions")
695	for _, p := range tokenPermissionLines(j.wf, j.jb) {
696		j.out(1, p.name+": "+p.level)
697	}
698	j.out(1, "##[endgroup]")
699	// secret_source is a github context key (JobExtension.cs:172); the context
700	// of a job from ermite lacks it, so it is added with the recorded value.
701	src := j.state.GitHub("secret_source")
702	if src == "" {
703		src = defaultSecretSource
704		j.state.SetGitHub("secret_source", src)
705	}
706	j.out(1, "Secret source: "+src)
707	j.out(1, "Cache mode: "+defaultCacheMode)
708}
709
710// jobDisplayName is the name of the job as GitHub shows it and the runner
711// prints it after "Complete job name:": the "name" of the job (the job key when
712// it has none) evaluated with github, needs, strategy, matrix, vars and inputs
713// as the server has them (State.ServerContexts: github without the keys the
714// runner adds), followed by the matrix values in parentheses unless the name
715// reads the matrix (recorded: matrix-job-name, see workflow.Job.DisplayName).
716// When the name cannot be worked out the job key is used and a warning is
717// logged: the name is not worth failing the job for.
718func (j *job) jobDisplayName() string {
719	ctx := context.Background()
720	fallback := func(why string, err error) string {
721		j.cfg.Log.Warn().Err(err).Str("job_key", j.cfg.JobKey).Msg("job display name: " + why + ", using the job key")
722		return j.cfg.JobKey
723	}
724	j.mu.Lock()
725	all := j.state.ServerContexts()
726	j.mu.Unlock()
727	env := j.evalEnv(newOrdered())
728	env.Contexts = all
729	// The strategy itself sees neither the strategy nor the matrix context.
730	senv := env
731	senv.Contexts = map[string]expr.Value{}
732	for _, k := range []string{"github", "needs", "vars", "inputs"} {
733		senv.Contexts[k] = all[k]
734	}
735	st, err := j.jb.EvaluateStrategy(ctx, senv)
736	if err != nil {
737		return fallback("the strategy does not evaluate", err)
738	}
739	idx, ok := comboIndex(st, all["strategy"], all["matrix"])
740	if !ok {
741		return fallback("the matrix of the job is not one of the combinations of its strategy", nil)
742	}
743	name, err := j.jb.DisplayName(ctx, env, st, idx)
744	if err != nil {
745		return fallback("the name does not evaluate", err)
746	}
747	// GitHub shows "Display name of", not "Display name of " (recorded:
748	// matrix-include-exclude, an empty last matrix value). The recordings only
749	// show trailing spaces; both ends are trimmed, as ermite's server does.
750	return strings.TrimSpace(name)
751}
752
753// comboIndex finds the combination of the strategy that is this job. Without a
754// matrix it is the only one. The index of the strategy context is trusted only
755// when that combination has the matrix of the job; otherwise the first
756// combination with equal values is taken. ok is false when there is none.
757func comboIndex(st *workflow.Strategy, strategyCtx, matrix expr.Value) (idx int, ok bool) {
758	if !st.HasMatrix {
759		return 0, true
760	}
761	m, isDict := matrix.(*expr.Dictionary)
762	if !isDict {
763		return 0, false
764	}
765	if d, isDict := strategyCtx.(*expr.Dictionary); isDict {
766		if n, found := d.Get("job-index"); found {
767			if f, isNum := n.(expr.Number); isNum && f >= 0 && int(f) < len(st.Combos) && sameMatrix(st.Combos[int(f)].Values, m) {
768				return int(f), true
769			}
770		}
771	}
772	for i, c := range st.Combos {
773		if sameMatrix(c.Values, m) {
774			return i, true
775		}
776	}
777	return 0, false
778}
779
780// sameMatrix compares two matrix values: the same keys (ignoring case and
781// order) with equal values.
782func sameMatrix(a, b *expr.Dictionary) bool {
783	if a.Len() != b.Len() {
784		return false
785	}
786	for _, p := range a.Pairs() {
787		v, found := b.Get(p.Key)
788		if !found || expr.ToJSON(v) != expr.ToJSON(p.Value) {
789			return false
790		}
791	}
792	return true
793}

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