v0.3.0 8f98fd3

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

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