v0.1.0 cbb8ad6

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

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