detached 61fc6ee

feat(host): make runner.environment configurable, github-hosted by default

commit
61fc6ee26cf05e05562d9c4ab0483c266c5a3c11
author
brice · · 2026-10-12 22:44 UTC
parent
81b458d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +54 -7
M README.md +1 -0
@@ -94,6 +94,7 @@ the host process; the sandbox receives the workflow and its contexts only.
9494 | `ERMITE_RUNNER_URL` | base URL of ermite, as in a browser (the runner adds `/-/api` itself) |
9595 | `ERMITE_RUNNER_NAME` | runner name (default: the host name) |
9696 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
97+| `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` (default) or `self-hosted`: the `runner.environment` value the jobs see. ermite's runners stand in for GitHub-hosted ones, so workflows written for github.com (`runner.environment == 'github-hosted'`) behave the same; `self-hosted` also makes "Set up job" print the runner and machine name, as GitHub does for self-hosted runners |
9798 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) |
9899 | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) |
99100 | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` |
@@ -94,6 +94,7 @@ the host process; the sandbox receives the workflow and its contexts only.
94 | `ERMITE_RUNNER_URL` | base URL of ermite, as in a browser (the runner adds `/-/api` itself) |94 | `ERMITE_RUNNER_URL` | base URL of ermite, as in a browser (the runner adds `/-/api` itself) |
95 | `ERMITE_RUNNER_NAME` | runner name (default: the host name) |95 | `ERMITE_RUNNER_NAME` | runner name (default: the host name) |
96 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |96 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
97+| `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` (default) or `self-hosted`: the `runner.environment` value the jobs see. ermite's runners stand in for GitHub-hosted ones, so workflows written for github.com (`runner.environment == 'github-hosted'`) behave the same; `self-hosted` also makes "Set up job" print the runner and machine name, as GitHub does for self-hosted runners |
97 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) |98 | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) |
98 | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) |99 | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) |
99 | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` |100 | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` |
M internal/execstream/exec.proto +1 -1
@@ -57,7 +57,7 @@ message JobSpec {
5757 int32 timeout_minutes = 7;
5858 string runner_name = 8;
5959 string runner_version = 9;
60- // runner_environment is the runner.environment value ("self-hosted").
60+ // runner_environment is the runner.environment value ("github-hosted" or "self-hosted").
6161 string runner_environment = 10;
6262 bool debug = 11; // ACTIONS_STEP_DEBUG
6363 // deadline_ms is what is left of the job timeout when the host sends this
@@ -57,7 +57,7 @@ message JobSpec {
57 int32 timeout_minutes = 7;57 int32 timeout_minutes = 7;
58 string runner_name = 8;58 string runner_name = 8;
59 string runner_version = 9;59 string runner_version = 9;
60- // runner_environment is the runner.environment value ("self-hosted").60+ // runner_environment is the runner.environment value ("github-hosted" or "self-hosted").
61 string runner_environment = 10;61 string runner_environment = 10;
62 bool debug = 11; // ACTIONS_STEP_DEBUG62 bool debug = 11; // ACTIONS_STEP_DEBUG
63 // deadline_ms is what is left of the job timeout when the host sends this63 // deadline_ms is what is left of the job timeout when the host sends this
M internal/execstream/execpb/exec.pb.go +1 -1
@@ -522,7 +522,7 @@ type JobSpec struct {
522522 TimeoutMinutes int32 `protobuf:"varint,7,opt,name=timeout_minutes,json=timeoutMinutes,proto3" json:"timeout_minutes,omitempty"`
523523 RunnerName string `protobuf:"bytes,8,opt,name=runner_name,json=runnerName,proto3" json:"runner_name,omitempty"`
524524 RunnerVersion string `protobuf:"bytes,9,opt,name=runner_version,json=runnerVersion,proto3" json:"runner_version,omitempty"`
525- // runner_environment is the runner.environment value ("self-hosted").
525+ // runner_environment is the runner.environment value ("github-hosted" or "self-hosted").
526526 RunnerEnvironment string `protobuf:"bytes,10,opt,name=runner_environment,json=runnerEnvironment,proto3" json:"runner_environment,omitempty"`
527527 Debug bool `protobuf:"varint,11,opt,name=debug,proto3" json:"debug,omitempty"` // ACTIONS_STEP_DEBUG
528528 // deadline_ms is what is left of the job timeout when the host sends this
@@ -522,7 +522,7 @@ type JobSpec struct {
522 TimeoutMinutes int32 `protobuf:"varint,7,opt,name=timeout_minutes,json=timeoutMinutes,proto3" json:"timeout_minutes,omitempty"`522 TimeoutMinutes int32 `protobuf:"varint,7,opt,name=timeout_minutes,json=timeoutMinutes,proto3" json:"timeout_minutes,omitempty"`
523 RunnerName string `protobuf:"bytes,8,opt,name=runner_name,json=runnerName,proto3" json:"runner_name,omitempty"`523 RunnerName string `protobuf:"bytes,8,opt,name=runner_name,json=runnerName,proto3" json:"runner_name,omitempty"`
524 RunnerVersion string `protobuf:"bytes,9,opt,name=runner_version,json=runnerVersion,proto3" json:"runner_version,omitempty"`524 RunnerVersion string `protobuf:"bytes,9,opt,name=runner_version,json=runnerVersion,proto3" json:"runner_version,omitempty"`
525- // runner_environment is the runner.environment value ("self-hosted").525+ // runner_environment is the runner.environment value ("github-hosted" or "self-hosted").
526 RunnerEnvironment string `protobuf:"bytes,10,opt,name=runner_environment,json=runnerEnvironment,proto3" json:"runner_environment,omitempty"`526 RunnerEnvironment string `protobuf:"bytes,10,opt,name=runner_environment,json=runnerEnvironment,proto3" json:"runner_environment,omitempty"`
527 Debug bool `protobuf:"varint,11,opt,name=debug,proto3" json:"debug,omitempty"` // ACTIONS_STEP_DEBUG527 Debug bool `protobuf:"varint,11,opt,name=debug,proto3" json:"debug,omitempty"` // ACTIONS_STEP_DEBUG
528 // deadline_ms is what is left of the job timeout when the host sends this528 // deadline_ms is what is left of the job timeout when the host sends this
M internal/host/config.go +37 -3
@@ -20,15 +20,24 @@ import (
2020 // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package
2121 // sandbox.
2222 const (
23- EnvURL = "ERMITE_RUNNER_URL"
24- EnvName = "ERMITE_RUNNER_NAME"
25- EnvLabels = "ERMITE_RUNNER_LABELS"
23+ EnvURL = "ERMITE_RUNNER_URL"
24+ EnvName = "ERMITE_RUNNER_NAME"
25+ EnvLabels = "ERMITE_RUNNER_LABELS"
26+ // EnvEnvironment is the runner.environment value that jobs see: see
27+ // Config.Environment.
28+ EnvEnvironment = "ERMITE_RUNNER_ENVIRONMENT"
2629 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"
2730 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The
2831 // runner token and the job tokens then travel in clear: tests only.
2932 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"
3033 )
3134
35+// The values of Config.Environment, as GitHub spells them.
36+const (
37+ EnvGitHubHosted = "github-hosted"
38+ EnvSelfHosted = "self-hosted"
39+)
40+
3241 // Config is the host part of the runner configuration.
3342 type Config struct {
3443 // URL is the base URL of the ermite server.
@@ -43,6 +52,14 @@ type Config struct {
4352 Capacity int
4453 // Version is the version of the runner binary.
4554 Version string
55+ // Environment is the "runner.environment" value of the jobs and what decides
56+ // whether "Set up job" prints the runner and machine name: EnvGitHubHosted
57+ // (the default) or EnvSelfHosted. The runners of ermite stand in for the
58+ // GitHub-hosted ones: workflows written for github.com test
59+ // runner.environment == 'github-hosted' and the recordings of github.com
60+ // (ermite-conformance) carry that value, so it is the default; an operator
61+ // who wants jobs to see a self-hosted runner sets ERMITE_RUNNER_ENVIRONMENT.
62+ Environment string
4663 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).
4764 InsecureHTTP bool
4865 }
@@ -55,6 +72,15 @@ func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient {
5572 return NewClient(url, token, http.DefaultTransport)
5673 }
5774
75+// environment is Environment, the default when a Config was built without
76+// LoadConfig.
77+func (c Config) environment() string {
78+ if c.Environment == "" {
79+ return EnvGitHubHosted
80+ }
81+ return c.Environment
82+}
83+
5884 var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
5985
6086 // LoadConfig reads the host settings. hostname is the default runner name.
@@ -65,6 +91,14 @@ func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity
6591 if c.Name == "" {
6692 c.Name = sanitizeName(hostname)
6793 }
94+ switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
95+ case "":
96+ c.Environment = EnvGitHubHosted
97+ case EnvGitHubHosted, EnvSelfHosted:
98+ c.Environment = v
99+ default:
100+ return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
101+ }
68102 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
69103 case "", "0", "false", "no":
70104 case "1", "true", "yes":
@@ -20,15 +20,24 @@ import (
20 // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package20 // (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package
21 // sandbox.21 // sandbox.
22 const (22 const (
23- EnvURL = "ERMITE_RUNNER_URL"23+ EnvURL = "ERMITE_RUNNER_URL"
24- EnvName = "ERMITE_RUNNER_NAME"24+ EnvName = "ERMITE_RUNNER_NAME"
25- EnvLabels = "ERMITE_RUNNER_LABELS"25+ EnvLabels = "ERMITE_RUNNER_LABELS"
26+ // EnvEnvironment is the runner.environment value that jobs see: see
27+ // Config.Environment.
28+ EnvEnvironment = "ERMITE_RUNNER_ENVIRONMENT"
26 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"29 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"
27 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The30 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The
28 // runner token and the job tokens then travel in clear: tests only.31 // runner token and the job tokens then travel in clear: tests only.
29 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"32 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"
30 )33 )
31 34
35+// The values of Config.Environment, as GitHub spells them.
36+const (
37+ EnvGitHubHosted = "github-hosted"
38+ EnvSelfHosted = "self-hosted"
39+)
40+
32 // Config is the host part of the runner configuration.41 // Config is the host part of the runner configuration.
33 type Config struct {42 type Config struct {
34 // URL is the base URL of the ermite server.43 // URL is the base URL of the ermite server.
@@ -43,6 +52,14 @@ type Config struct {
43 Capacity int52 Capacity int
44 // Version is the version of the runner binary.53 // Version is the version of the runner binary.
45 Version string54 Version string
55+ // Environment is the "runner.environment" value of the jobs and what decides
56+ // whether "Set up job" prints the runner and machine name: EnvGitHubHosted
57+ // (the default) or EnvSelfHosted. The runners of ermite stand in for the
58+ // GitHub-hosted ones: workflows written for github.com test
59+ // runner.environment == 'github-hosted' and the recordings of github.com
60+ // (ermite-conformance) carry that value, so it is the default; an operator
61+ // who wants jobs to see a self-hosted runner sets ERMITE_RUNNER_ENVIRONMENT.
62+ Environment string
46 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).63 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).
47 InsecureHTTP bool64 InsecureHTTP bool
48 }65 }
@@ -55,6 +72,15 @@ func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient {
55 return NewClient(url, token, http.DefaultTransport)72 return NewClient(url, token, http.DefaultTransport)
56 }73 }
57 74
75+// environment is Environment, the default when a Config was built without
76+// LoadConfig.
77+func (c Config) environment() string {
78+ if c.Environment == "" {
79+ return EnvGitHubHosted
80+ }
81+ return c.Environment
82+}
83+
58 var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)84 var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
59 85
60 // LoadConfig reads the host settings. hostname is the default runner name.86 // LoadConfig reads the host settings. hostname is the default runner name.
@@ -65,6 +91,14 @@ func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity
65 if c.Name == "" {91 if c.Name == "" {
66 c.Name = sanitizeName(hostname)92 c.Name = sanitizeName(hostname)
67 }93 }
94+ switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
95+ case "":
96+ c.Environment = EnvGitHubHosted
97+ case EnvGitHubHosted, EnvSelfHosted:
98+ c.Environment = v
99+ default:
100+ return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
101+ }
68 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {102 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
69 case "", "0", "false", "no":103 case "", "0", "false", "no":
70 case "1", "true", "yes":104 case "1", "true", "yes":
M internal/host/job.go +1 -1
@@ -634,7 +634,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
634634 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
635635 Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
636636 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
637- RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted",
637+ RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),
638638 }}
639639 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
640640 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
@@ -634,7 +634,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
634 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,634 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
635 Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),635 Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
636 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),636 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
637- RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: "self-hosted",637+ RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),
638 }}638 }}
639 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{639 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
640 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,640 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
M internal/host/runner_test.go +13 -1
@@ -80,7 +80,7 @@ func TestTheJobTokenAndTheRunnerTokenStayApart(t *testing.T) {
8080 h.run(t)
8181 eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
8282 // The sandbox gets the workflow and contexts, never a token.
83- if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "self-hosted" || sawJobSpec.TimeoutMinutes != 5 {
83+ if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "github-hosted" || sawJobSpec.TimeoutMinutes != 5 {
8484 t.Fatalf("%v", sawJobSpec)
8585 }
8686 if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") {
@@ -785,6 +785,18 @@ func TestLoadConfig(t *testing.T) {
785785 if c.URL != "https://ermite.example" || c.Name != "my-host.local" || strings.Join(c.Labels, ",") != "ubuntu-latest,Ubuntu-24.04" || c.Capacity != 2 {
786786 t.Fatalf("%+v", c)
787787 }
788+ if c.Environment != "github-hosted" {
789+ t.Errorf("default environment %q", c.Environment)
790+ }
791+ env[EnvEnvironment] = " Self-Hosted "
792+ if c, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err != nil || c.Environment != "self-hosted" {
793+ t.Errorf("self-hosted: %q, %v", c.Environment, err)
794+ }
795+ env[EnvEnvironment] = "cloud"
796+ if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil || !strings.Contains(err.Error(), EnvEnvironment) {
797+ t.Errorf("an invalid environment was accepted: %v", err)
798+ }
799+ delete(env, EnvEnvironment)
788800 env[EnvName] = "bad name"
789801 if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil {
790802 t.Error("an invalid name was accepted")
@@ -80,7 +80,7 @@ func TestTheJobTokenAndTheRunnerTokenStayApart(t *testing.T) {
80 h.run(t)80 h.run(t)
81 eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })81 eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
82 // The sandbox gets the workflow and contexts, never a token.82 // The sandbox gets the workflow and contexts, never a token.
83- if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "self-hosted" || sawJobSpec.TimeoutMinutes != 5 {83+ if sawJobSpec == nil || sawJobSpec.JobKey != "j" || sawJobSpec.RunnerName != "r1" || sawJobSpec.RunnerEnvironment != "github-hosted" || sawJobSpec.TimeoutMinutes != 5 {
84 t.Fatalf("%v", sawJobSpec)84 t.Fatalf("%v", sawJobSpec)
85 }85 }
86 if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") {86 if strings.Contains(sawJobSpec.String(), "ermitejob_") || strings.Contains(sawJobSpec.String(), "ermiterun_") {
@@ -785,6 +785,18 @@ func TestLoadConfig(t *testing.T) {
785 if c.URL != "https://ermite.example" || c.Name != "my-host.local" || strings.Join(c.Labels, ",") != "ubuntu-latest,Ubuntu-24.04" || c.Capacity != 2 {785 if c.URL != "https://ermite.example" || c.Name != "my-host.local" || strings.Join(c.Labels, ",") != "ubuntu-latest,Ubuntu-24.04" || c.Capacity != 2 {
786 t.Fatalf("%+v", c)786 t.Fatalf("%+v", c)
787 }787 }
788+ if c.Environment != "github-hosted" {
789+ t.Errorf("default environment %q", c.Environment)
790+ }
791+ env[EnvEnvironment] = " Self-Hosted "
792+ if c, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err != nil || c.Environment != "self-hosted" {
793+ t.Errorf("self-hosted: %q, %v", c.Environment, err)
794+ }
795+ env[EnvEnvironment] = "cloud"
796+ if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil || !strings.Contains(err.Error(), EnvEnvironment) {
797+ t.Errorf("an invalid environment was accepted: %v", err)
798+ }
799+ delete(env, EnvEnvironment)
788 env[EnvName] = "bad name"800 env[EnvName] = "bad name"
789 if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil {801 if _, err := LoadConfig(func(k string) string { return env[k] }, "h", "/s", 1, ""); err == nil {
790 t.Error("an invalid name was accepted")802 t.Error("an invalid name was accepted")

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