v0.4.1 122e784

contexts.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"encoding/base64"
7	"fmt"
8	"maps"
9	"path/filepath"
10	"runtime"
11	"slices"
12	"strings"
13
14	"bnema.dev/ermite-actions/expr"
15)
16
17// ordered is a string map that keeps the position of a key when it is set
18// again, like the Dictionary<string,string> the official runner uses for
19// Global.EnvironmentVariables and the step environment (the order shows in the
20// "env:" block of the log). Keys are case-sensitive, as on Linux
21// (VarUtil.EnvironmentVariableKeyComparer).
22type ordered struct {
23	keys []string
24	vals map[string]string
25}
26
27func newOrdered() *ordered { return &ordered{vals: map[string]string{}} }
28
29func (o *ordered) Set(k, v string) {
30	if _, ok := o.vals[k]; !ok {
31		o.keys = append(o.keys, k)
32	}
33	o.vals[k] = v
34}
35
36func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok }
37func (o *ordered) Remove(k string) {
38	if _, ok := o.vals[k]; !ok {
39		return
40	}
41	delete(o.vals, k)
42	o.keys = slices.DeleteFunc(o.keys, func(x string) bool { return x == k })
43}
44func (o *ordered) Len() int       { return len(o.keys) }
45func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
46
47func (o *ordered) Clone() *ordered {
48	c := newOrdered()
49	for _, k := range o.keys {
50		c.Set(k, o.vals[k])
51	}
52	return c
53}
54
55// Dict is the value of the env context. The context of the official runner is
56// case-sensitive on Linux (CaseSensitiveDictionaryContextData); package expr
57// has only case-insensitive dictionaries, so two names that differ by case
58// collapse to the first in expressions (they stay distinct in the process
59// environment). A known deviation.
60func (o *ordered) Dict() *expr.Dictionary {
61	d := expr.NewDictionary()
62	for _, k := range o.keys {
63		d.Add(k, expr.String(o.vals[k]))
64	}
65	return d
66}
67
68// setKey returns d with key set to v: the value is replaced in place when the
69// key (compared case-insensitively) exists, else appended.
70func setKey(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
71	out := expr.NewDictionary()
72	done := false
73	for _, p := range d.Pairs() {
74		if strings.EqualFold(p.Key, key) {
75			if !done {
76				out.Add(p.Key, v)
77				done = true
78			}
79			continue
80		}
81		out.Add(p.Key, p.Value)
82	}
83	if !done {
84		out.Add(key, v)
85	}
86	return out
87}
88
89// setKeySorted is setKey for the github context: a key that is not there yet
90// goes before the first key that sorts after it (bytewise; "token" stays first)
91// instead of at the end, so that the keys the runner adds (workspace,
92// secret_source, the file commands...) sit where the ermite-conformance
93// recordings list them. Note that the recordings are normalised with sorted
94// keys, so they show the sorted order, not necessarily the insertion order of
95// GitHub's runner (SetGitHubContext appends). Keys that exist are replaced in
96// place.
97func setKeySorted(d *expr.Dictionary, key string, v expr.Value) *expr.Dictionary {
98	for _, p := range d.Pairs() {
99		if strings.EqualFold(p.Key, key) {
100			return setKey(d, key, v)
101		}
102	}
103	out := expr.NewDictionary()
104	done := false
105	for _, p := range d.Pairs() {
106		if !done && p.Key > key && !strings.EqualFold(p.Key, "token") {
107			out.Add(key, v)
108			done = true
109		}
110		out.Add(p.Key, p.Value)
111	}
112	if !done {
113		out.Add(key, v)
114	}
115	return out
116}
117
118func str(d *expr.Dictionary, key string) string {
119	if v, ok := d.Get(key); ok {
120		if s, ok := v.(expr.String); ok {
121			return string(s)
122		}
123	}
124	return ""
125}
126
127// Layout is the directory layout of a job, like a hosted runner's
128// (/home/runner/work/<repo>/<repo>, RUNNER_TEMP, RUNNER_TOOL_CACHE).
129type Layout struct {
130	Home string // HOME of the runner user
131	// WorkRoot is the _work directory: <Home>/work.
132	WorkRoot string
133	// Temp is RUNNER_TEMP: <WorkRoot>/_temp.
134	Temp string
135	// ToolCache is RUNNER_TOOL_CACHE.
136	ToolCache string
137	// RunnerWorkspace is RUNNER_WORKSPACE: <WorkRoot>/<repo>.
138	RunnerWorkspace string
139	// Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>.
140	Workspace string
141	// Actions is where the actions of the job are downloaded: <WorkRoot>/_actions.
142	Actions string
143}
144
145// DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image.
146const DefaultToolCache = "/opt/hostedtoolcache"
147
148// NewLayout lays out a job of the repository repo (its name, without owner)
149// under home. An empty toolCache means DefaultToolCache.
150func NewLayout(home, repo, toolCache string) Layout {
151	if toolCache == "" {
152		toolCache = DefaultToolCache
153	}
154	work := filepath.Join(home, "work")
155	return Layout{
156		Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache,
157		RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo),
158		Actions: filepath.Join(work, "_actions"),
159	}
160}
161
162// RunnerInfo describes the runner for the runner context.
163type RunnerInfo struct {
164	Name string
165	// Environment is "self-hosted" (the default) or "github-hosted".
166	Environment string
167	// Arch is "X64" or "ARM64"; empty means the architecture of this process.
168	Arch string
169}
170
171// githubHosted reports whether the runner.environment is "github-hosted". An
172// empty Environment is "self-hosted", the default of the official runner's
173// runner context.
174func (r RunnerInfo) githubHosted() bool { return r.Environment == "github-hosted" }
175
176func (r RunnerInfo) arch() string {
177	if r.Arch != "" {
178		return r.Arch
179	}
180	switch runtime.GOARCH {
181	case "amd64":
182		return "X64"
183	case "arm64":
184		return "ARM64"
185	case "arm":
186		return "ARM"
187	}
188	return "X86"
189}
190
191// repoName returns the repository name of "owner/name".
192func repoName(full string) string {
193	if i := strings.LastIndexByte(full, '/'); i >= 0 {
194		return full[i+1:]
195	}
196	return full
197}
198
199// stepCtx is one entry of the steps context.
200type stepCtx struct {
201	outputs *ordered
202	outcome string // "" until the step completes
203	concl   string
204}
205
206// State holds the contexts of a job as the official runner does
207// (ExecutionContext.ExpressionValues): the contexts the server computed, and
208// github, runner, job, env and steps, which the runner owns.
209type State struct {
210	layout Layout
211	github *expr.Dictionary
212	// serverGitHub is the github context as the server sent it, before the
213	// runner added its keys (token, workspace, the file commands, secret_source,
214	// action...): the contexts that the server itself evaluates the job name
215	// with (see ServerContexts).
216	serverGitHub *expr.Dictionary
217	runner       *expr.Dictionary
218	job          *expr.Dictionary
219	// other holds needs, strategy, matrix, inputs, vars: copied from the server.
220	other map[string]expr.Value
221	// env is Global.EnvironmentVariables: the job-level environment and what
222	// GITHUB_ENV adds. A step's own env is not in it.
223	env *ordered
224	// root is the steps context of the job; cur is the one in use: a composite
225	// action's steps see their own (see PushScope).
226	root, cur *stepsScope
227	scopes    map[string]*stepsScope
228	// prepend is Global.PrependPath in the order of the GITHUB_PATH entries.
229	prepend []string
230	// status is job.status: "success", "failure" or "cancelled".
231	status string
232	// jobSecrets are Job.secrets: the secrets context besides GITHUB_TOKEN.
233	// They live in memory only (never in a file, a log or an error).
234	jobSecrets map[string]string
235}
236
237// WithJobData gives the state the secrets and variables of the job message
238// (Job.secrets and Job.vars). The vars context keeps what the server wrote in
239// the contexts JSON; the variables are its fallback when the JSON has none.
240func (s *State) WithJobData(secrets, vars map[string]string) {
241	s.jobSecrets = secrets
242	if d, ok := s.other["vars"].(*expr.Dictionary); (!ok || d.Len() == 0) && len(vars) > 0 {
243		nd := expr.NewDictionary()
244		for _, k := range slices.Sorted(maps.Keys(vars)) {
245			nd.Add(k, expr.String(vars[k]))
246		}
247		s.other["vars"] = nd
248	}
249}
250
251// serverContexts are the keys of the contexts JSON that the runner reads.
252var serverContexts = []string{"needs", "strategy", "matrix", "inputs", "vars"}
253
254// NewState builds the contexts of a job from the JSON the server sent (the
255// "contexts" field of the job) and the matrix JSON, with the context
256// initialisation of JobRunner.cs:142-177 and ExecutionContext.InitializeJob
257// (ExecutionContext.cs:1001-1056).
258func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallback string) (*State, Layout, error) {
259	root := expr.NewDictionary()
260	if strings.TrimSpace(contextsJSON) != "" {
261		v, err := expr.FromJSON([]byte(contextsJSON))
262		if err != nil {
263			return nil, Layout{}, fmt.Errorf("job contexts: %w", err)
264		}
265		d, ok := v.(*expr.Dictionary)
266		if !ok {
267			return nil, Layout{}, fmt.Errorf("job contexts: want a JSON object")
268		}
269		root = d
270	}
271	s := &State{other: map[string]expr.Value{}, env: newOrdered(), status: "success", scopes: map[string]*stepsScope{}}
272	s.root = newScope()
273	s.cur = s.root
274
275	// github: token first, then what the server gave (ExecutionContext.cs:1023-1037).
276	// The token is the one the server sent in github.token (GitHub's
277	// system.github.token variable), empty when it sent none.
278	gh := expr.NewDictionary()
279	gh.Add("token", expr.String(""))
280	if v, ok := root.Get("github"); ok {
281		d, ok := v.(*expr.Dictionary)
282		if !ok {
283			return nil, Layout{}, fmt.Errorf("job contexts: github must be an object")
284		}
285		for _, p := range d.Pairs() {
286			if strings.EqualFold(p.Key, "token") {
287				if t, isStr := p.Value.(expr.String); isStr {
288					gh = setKey(gh, "token", t)
289				}
290				continue
291			}
292			gh.Add(p.Key, p.Value)
293		}
294	}
295	s.serverGitHub = expr.NewDictionary()
296	if v, ok := root.Get("github"); ok {
297		s.serverGitHub = v.(*expr.Dictionary)
298		// GitHub evaluates names with github.job empty (recorded:
299		// ctx-github-names): the job key is only added to the contexts the
300		// job runs with.
301		if _, has := s.serverGitHub.Get("job"); has {
302			s.serverGitHub = setKey(s.serverGitHub, "job", expr.String(""))
303		}
304	}
305	if str(gh, "job") == "" && jobKey != "" {
306		gh = setKeySorted(gh, "job", expr.String(jobKey))
307	}
308	repo := repoName(str(gh, "repository"))
309	if repo == "" {
310		repo = repoFallback
311	}
312	if repo == "" {
313		return nil, Layout{}, fmt.Errorf("job contexts: github.repository is missing")
314	}
315	home := "/home/runner"
316	s.layout = NewLayout(home, repo, "")
317	s.github = setKeySorted(gh, "workspace", expr.String(s.layout.Workspace))
318	return s, s.layout, s.initRest(root, matrixJSON, r)
319}
320
321// WithLayout replaces the directory layout (tests and non-default homes). It
322// must be called before the first step.
323func (s *State) WithLayout(l Layout) {
324	s.layout = l
325	s.github = setKeySorted(s.github, "workspace", expr.String(l.Workspace))
326	s.runner = setKey(s.runner, "temp", expr.String(l.Temp))
327	s.runner = setKey(s.runner, "tool_cache", expr.String(l.ToolCache))
328	s.runner = setKey(s.runner, "workspace", expr.String(l.RunnerWorkspace))
329}
330
331func (s *State) initRest(root *expr.Dictionary, matrixJSON string, r RunnerInfo) error {
332	for _, k := range serverContexts {
333		if v, ok := root.Get(k); ok {
334			s.other[k] = v
335		}
336	}
337	if matrixJSON != "" {
338		if v, err := expr.FromJSON([]byte(matrixJSON)); err == nil {
339			if d, ok := v.(*expr.Dictionary); ok && d.Len() > 0 {
340				s.other["matrix"] = d
341			}
342		} else {
343			return fmt.Errorf("job matrix: %w", err)
344		}
345	}
346	// A job without a matrix has the matrix context null on github.com, not an
347	// empty object (recordings: "matrix": null in the 176 of 221 jobs that have
348	// none, and toJSON(matrix) prints null). The server sends "{}" for such a
349	// job (runner.proto: Job.matrix), so an empty matrix becomes null here.
350	// matrix.x still works: a property of null is null, as for a missing key.
351	if d, ok := s.other["matrix"].(*expr.Dictionary); !ok || d.Len() == 0 {
352		s.other["matrix"] = expr.Null{}
353	}
354	// A context the job does not have is an empty object, like the server's
355	// ("needs": {}, "inputs": {}, "vars": {}); matrix and strategy are null
356	// when absent (recordings: "matrix": null).
357	for _, k := range []string{"needs", "inputs", "vars"} {
358		if _, ok := s.other[k]; !ok {
359			s.other[k] = expr.NewDictionary()
360		}
361	}
362
363	// runner: os, arch, name, environment, tool_cache, temp (JobRunner.cs:165-176,
364	// TempDirectoryManager.cs:31) and workspace (JobExtension.cs:215).
365	env := r.Environment
366	if env == "" {
367		env = "self-hosted"
368	}
369	rc := expr.NewDictionary()
370	rc.Add("os", expr.String("Linux"))
371	rc.Add("arch", expr.String(r.arch()))
372	rc.Add("name", expr.String(r.Name))
373	rc.Add("environment", expr.String(env))
374	rc.Add("tool_cache", expr.String(s.layout.ToolCache))
375	rc.Add("temp", expr.String(s.layout.Temp))
376	rc.Add("workspace", expr.String(s.layout.RunnerWorkspace))
377	s.runner = rc
378
379	// job: status first (StepsRunner.cs:53), then what the server gave.
380	jc := expr.NewDictionary()
381	jc.Add("status", expr.String("success"))
382	if v, ok := root.Get("job"); ok {
383		if d, ok := v.(*expr.Dictionary); ok {
384			for _, p := range d.Pairs() {
385				if !strings.EqualFold(p.Key, "status") {
386					jc.Add(p.Key, p.Value)
387				}
388			}
389		}
390	}
391	s.job = jc
392	return nil
393}
394
395// Layout returns the directory layout.
396func (s *State) Layout() Layout { return s.layout }
397
398// SetGitHub sets a key of the github context (SetGitHubContext).
399func (s *State) SetGitHub(k, v string) { s.github = setKeySorted(s.github, k, expr.String(v)) }
400
401// GitHub returns a string key of the github context ("" when absent).
402func (s *State) GitHub(k string) string { return str(s.github, k) }
403
404// Event returns github.event, or nil.
405func (s *State) Event() expr.Value { v, _ := s.github.Get("event"); return v }
406
407// SetEnv sets a variable of the job environment (Global.EnvironmentVariables
408// and the env context).
409func (s *State) SetEnv(k, v string) { s.env.Set(k, v) }
410
411// JobEnv returns a copy of the job environment.
412func (s *State) JobEnv() *ordered { return s.env.Clone() }
413
414// AddPath records a GITHUB_PATH entry: a repeated entry moves to the end
415// (AddPathFileCommand, FileCommandManager.cs:152).
416func (s *State) AddPath(p string) {
417	s.prepend = slices.DeleteFunc(s.prepend, func(x string) bool { return x == p })
418	s.prepend = append(s.prepend, p)
419}
420
421// Prepend returns the GITHUB_PATH entries in the order added.
422func (s *State) Prepend() []string { return slices.Clone(s.prepend) }
423
424// SetStatus sets job.status ("success", "failure" or "cancelled").
425func (s *State) SetStatus(status string) {
426	s.status = status
427	s.job = setKey(s.job, "status", expr.String(status))
428}
429
430// Status returns job.status.
431func (s *State) Status() string { return s.status }
432
433// stepIDVisible reports whether a step with this id appears in the steps
434// context: generated ids start with "__" and do not (ExecutionContext.cs:644).
435func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") }
436
437func (s *State) step(id string) *stepCtx {
438	c, ok := s.cur.steps[id]
439	if !ok {
440		c = &stepCtx{outputs: newOrdered()}
441		s.cur.steps[id] = c
442		s.cur.order = append(s.cur.order, id)
443	}
444	return c
445}
446
447// SetOutput sets an output of step id (StepsContext.SetOutput). Steps with a
448// generated id keep no outputs.
449func (s *State) SetOutput(id, name, value string) {
450	if !stepIDVisible(id) {
451		return
452	}
453	s.step(id).outputs.Set(name, value)
454}
455
456// SetStepResult records outcome and conclusion of a completed step
457// (ExecutionContext.UpdateGlobalStepsContext).
458func (s *State) SetStepResult(id, outcome, conclusion string) {
459	if !stepIDVisible(id) {
460		return
461	}
462	c := s.step(id)
463	c.outcome, c.concl = outcome, conclusion
464}
465
466// Output returns an output of a step ("" when absent).
467func (s *State) Output(id, name string) string {
468	if c, ok := s.cur.steps[id]; ok {
469		v, _ := c.outputs.Get(name)
470		return v
471	}
472	return ""
473}
474
475func (s *State) stepsValue() *expr.Dictionary {
476	d := expr.NewDictionary()
477	for _, id := range s.cur.order {
478		c := s.cur.steps[id]
479		sd := expr.NewDictionary()
480		sd.Add("outputs", c.outputs.Dict())
481		if c.outcome != "" {
482			sd.Add("outcome", expr.String(c.outcome))
483			sd.Add("conclusion", expr.String(c.concl))
484		}
485		d.Add(id, sd)
486	}
487	return d
488}
489
490// Values returns the contexts for expression evaluation. stepEnv, when not
491// nil, is the env context of a step (job environment with the step's own
492// variables); otherwise the env context is the job environment.
493func (s *State) Values(env *ordered) map[string]expr.Value {
494	if env == nil {
495		env = s.env
496	}
497	m := map[string]expr.Value{
498		"github":  s.github,
499		"runner":  s.runner,
500		"job":     s.job,
501		"env":     env.Dict(),
502		"steps":   s.stepsValue(),
503		"secrets": s.secrets(),
504	}
505	for k, v := range s.other {
506		m[k] = v
507	}
508	if s.cur.inputs != nil {
509		m["inputs"] = s.cur.inputs
510	}
511	return m
512}
513
514// secrets is the secrets context: GITHUB_TOKEN and the secrets of the job
515// (Job.secrets), as on GitHub where the message carries the token as the secret
516// GITHUB_TOKEN (recorded: ctx-inputs-vars, secrets.GITHUB_TOKEN == github.token).
517// An unknown secret evaluates to "" like on GitHub.
518//
519// GITHUB_TOKEN comes from the "token" key of the github context that the
520// server sends in Job.contexts, the same value as github.token. It must be a
521// token of its own, scoped to the job (the permissions "Set up job" prints) and
522// short lived. It must never be the "ermitejob_" job token: that one
523// authenticates the runner's calls to ermite, stays in the runner host process
524// and must not reach the sandbox (runner.proto, "Secrets and trust"). Without a
525// token there is no GITHUB_TOKEN entry, and a test of secrets.GITHUB_TOKEN
526// against the empty string is false. GITHUB_TOKEN is reserved: an entry of
527// that name in Job.secrets is ignored.
528func (s *State) secrets() *expr.Dictionary {
529	d := expr.NewDictionary()
530	if t := s.GitHub("token"); t != "" {
531		d.Add("GITHUB_TOKEN", expr.String(t))
532	}
533	for _, k := range slices.Sorted(maps.Keys(s.jobSecrets)) {
534		if strings.EqualFold(k, "GITHUB_TOKEN") {
535			continue
536		}
537		d.Add(k, expr.String(s.jobSecrets[k]))
538	}
539	return d
540}
541
542// Secrets returns the values that must never leave the job: the token and the
543// base64 of "x-access-token:<token>" (ExecutionContext.cs:1026-1028); newMasker
544// adds their other encodings. The secrets of the job are registered with
545// registerSecrets.
546func (s *State) Secrets() []string {
547	t := s.GitHub("token")
548	if t == "" {
549		return nil
550	}
551	return []string{t, base64.StdEncoding.EncodeToString([]byte("x-access-token:" + t))}
552}
553
554// registerSecrets registers every secret of the job as the runner does before
555// the first step (Worker.cs:158-171): the whole value trimmed, which may span
556// several lines because individual trace info can contain line breaks, then
557// each non-empty trimmed line of it, split on CR and LF, because child
558// processes are processed line by line. add ignores empty values and adds the
559// encoded forms. GITHUB_TOKEN is not a job secret (see State.secrets).
560func registerSecrets(m *masker, secrets map[string]string) {
561	for _, k := range slices.Sorted(maps.Keys(secrets)) {
562		if strings.EqualFold(k, "GITHUB_TOKEN") {
563			continue
564		}
565		v := strings.TrimSpace(secrets[k])
566		m.add(v)
567		for _, line := range strings.FieldsFunc(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
568			m.add(strings.TrimSpace(line))
569		}
570	}
571}
572
573// ServerContexts returns the contexts that the server has when it evaluates
574// the name of a job: github as received (without the keys the runner adds),
575// needs, strategy, matrix, vars and inputs.
576func (s *State) ServerContexts() map[string]expr.Value {
577	m := map[string]expr.Value{"github": s.serverGitHub}
578	for _, k := range []string{"needs", "strategy", "matrix", "vars", "inputs"} {
579		if v, ok := s.other[k]; ok {
580			m[k] = v
581		}
582	}
583	return m
584}
585
586// githubEnvAllowlist is GitHubContext._contextEnvAllowlist (GitHubContext.cs:9).
587var githubEnvAllowlist = map[string]bool{
588	"action_path": true, "action_ref": true, "action_repository": true, "action": true, "actor": true,
589	"actor_id": true, "api_url": true, "artifacts": true, "artifacts_list": true, "base_ref": true,
590	"env": true, "event_name": true, "event_path": true, "graphql_url": true, "head_ref": true,
591	"job": true, "output": true, "path": true, "ref_name": true, "ref_protected": true, "ref_type": true,
592	"ref": true, "repository": true, "repository_id": true, "repository_owner": true,
593	"repository_owner_id": true, "retention_days": true, "run_attempt": true, "run_id": true,
594	"run_number": true, "server_url": true, "sha": true, "state": true, "step_summary": true,
595	"triggering_actor": true, "workflow": true, "workflow_ref": true, "workflow_sha": true, "workspace": true,
596}
597
598// RuntimeEnv is the environment that the contexts add to every step: the
599// allow-listed keys of github as GITHUB_<KEY> (strings and booleans only, as in
600// GitHubContext.GetRuntimeEnvironmentVariables), and every key of runner as
601// RUNNER_<KEY> (RunnerContext.cs:11). Numbers and objects are skipped: the
602// server must send GitHub's string forms ("run_id": "123").
603func (s *State) RuntimeEnv() *ordered {
604	out := newOrdered()
605	for _, p := range s.github.Pairs() {
606		if !githubEnvAllowlist[strings.ToLower(p.Key)] {
607			continue
608		}
609		switch v := p.Value.(type) {
610		case expr.String:
611			out.Set("GITHUB_"+strings.ToUpper(p.Key), string(v))
612		case expr.Boolean:
613			out.Set("GITHUB_"+strings.ToUpper(p.Key), map[bool]string{true: "true", false: "false"}[bool(v)])
614		}
615	}
616	for _, p := range s.runner.Pairs() {
617		if v, ok := p.Value.(expr.String); ok {
618			out.Set("RUNNER_"+strings.ToUpper(p.Key), string(v))
619		}
620	}
621	return out
622}
623
624// EventJSON returns github.event as JSON for event.json, or "" without event.
625func (s *State) EventJSON() string {
626	v := s.Event()
627	if v == nil {
628		return ""
629	}
630	if _, isNull := v.(expr.Null); isNull {
631		return ""
632	}
633	return expr.ToJSON(v)
634}

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