detached f355cc5

fix(executor): mask the token in step names, the job error and job outputs

The masker was applied to log lines only. Every report now goes through it:
the names of the steps (StepStarted and StepCompleted) and Result.Error, so
nothing that reaches ermite holds the token. A job output whose value holds a
secret is dropped with GitHub's warning (JobExtension.cs:819-824), "Skip
output '<name>' since it may contain secret.". The message of an issue is
masked before it is cut at 4096 bytes, so a token split by the cut cannot
leave its start in the line.
commit
f355cc5a96d6d3dd5db1a8166ddeea5a9e858539
author
brice · · 2026-10-13 04:07 UTC
parent
5a16c3d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +222 -10
M internal/executor/contexts.go +3 -3
@@ -490,9 +490,9 @@ func (s *State) secrets() *expr.Dictionary {
490490 return d
491491 }
492492
493-// Secrets returns the values that must never appear in the job log: the
494-// token and the base64 of "x-access-token:<token>" the runner also masks
495-// (ExecutionContext.cs:1026-1028).
493+// Secrets returns the values that must never leave the job: the token and the
494+// base64 of "x-access-token:<token>" (ExecutionContext.cs:1026-1028); newMasker
495+// adds their other encodings.
496496 func (s *State) Secrets() []string {
497497 t := s.GitHub("token")
498498 if t == "" {
@@ -490,9 +490,9 @@ func (s *State) secrets() *expr.Dictionary {
490 return d490 return d
491 }491 }
492 492
493-// Secrets returns the values that must never appear in the job log: the493+// Secrets returns the values that must never leave the job: the token and the
494-// token and the base64 of "x-access-token:<token>" the runner also masks494+// base64 of "x-access-token:<token>" (ExecutionContext.cs:1026-1028); newMasker
495-// (ExecutionContext.cs:1026-1028).495+// adds their other encodings.
496 func (s *State) Secrets() []string {496 func (s *State) Secrets() []string {
497 t := s.GitHub("token")497 t := s.GitHub("token")
498 if t == "" {498 if t == "" {
M internal/executor/job.go +16 -7
@@ -10,6 +10,7 @@ import (
1010 "path/filepath"
1111 "strings"
1212 "sync"
13+ "sync/atomic"
1314 "syscall"
1415 "time"
1516
@@ -144,10 +145,13 @@ type job struct {
144145 baseEnv []string
145146 lookupPath string
146147 before map[string]bool
147- // masks are the secret values replaced by *** in every log line.
148- masks []string
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]
149151 }
150152
153+func (j *job) mask(s string) string { return j.masker.Load().mask(s) }
154+
151155 func (j *job) now() time.Time {
152156 if j.cfg.Now != nil {
153157 return j.cfg.Now()
@@ -162,6 +166,7 @@ func Run(cancel *Canceller, cfg Config) Result {
162166 cancel = NewCanceller()
163167 }
164168 j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()}
169+ j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}
165170 defer func() {
166171 if j.fc != nil {
167172 _ = j.fc.Close()
@@ -171,7 +176,8 @@ func Run(cancel *Canceller, cfg Config) Result {
171176 }
172177 }()
173178 res := j.run()
174- cfg.Reporter.JobCompleted(res)
179+ res.Error = j.mask(res.Error)
180+ j.cfg.Reporter.JobCompleted(res)
175181 return res
176182 }
177183
@@ -243,9 +249,6 @@ func (j *job) timeoutMinutes() int {
243249 func (j *job) write(num int, line string) {
244250 j.mu.Lock()
245251 defer j.mu.Unlock()
246- for _, m := range j.masks {
247- line = strings.ReplaceAll(line, m, "***")
248- }
249252 j.cfg.Reporter.Log(num, j.now(), line)
250253 }
251254
@@ -258,6 +261,7 @@ func (j *job) out(num int, text string) {
258261
259262 // tagged writes tag+message; only the first line carries the tag.
260263 func (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
261265 if len(msg) > maxIssueMessageLength && tag != "" {
262266 msg = msg[:maxIssueMessageLength]
263267 }
@@ -371,7 +375,7 @@ func (j *job) setUp() ([]*workflow.Step, error) {
371375 state.WithLayout(layout)
372376 }
373377 j.state = state
374- j.masks = state.Secrets()
378+ j.masker.Store(newMasker(state.Secrets()...))
375379
376380 // Features that arrive in later phases fail the job with a clear message.
377381 switch {
@@ -543,6 +547,11 @@ func (j *job) completeJob(num int) map[string]string {
543547 j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))
544548 continue
545549 }
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+ }
546555 // ermite refuses outputs above 1 MiB in total (runner.proto,
547556 // CompleteJobRequest.outputs), and they would not fit a frame of the
548557 // stream either: the job fails without outputs.
@@ -10,6 +10,7 @@ import (
10 "path/filepath"10 "path/filepath"
11 "strings"11 "strings"
12 "sync"12 "sync"
13+ "sync/atomic"
13 "syscall"14 "syscall"
14 "time"15 "time"
15 16
@@ -144,10 +145,13 @@ type job struct {
144 baseEnv []string145 baseEnv []string
145 lookupPath string146 lookupPath string
146 before map[string]bool147 before map[string]bool
147- // masks are the secret values replaced by *** in every log line.148+ // masker hides the secrets in everything reported; it is set once the
148- masks []string149+ // contexts are built, before any step runs.
150+ masker atomic.Pointer[masker]
149 }151 }
150 152
153+func (j *job) mask(s string) string { return j.masker.Load().mask(s) }
154+
151 func (j *job) now() time.Time {155 func (j *job) now() time.Time {
152 if j.cfg.Now != nil {156 if j.cfg.Now != nil {
153 return j.cfg.Now()157 return j.cfg.Now()
@@ -162,6 +166,7 @@ func Run(cancel *Canceller, cfg Config) Result {
162 cancel = NewCanceller()166 cancel = NewCanceller()
163 }167 }
164 j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()}168 j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()}
169+ j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter}
165 defer func() {170 defer func() {
166 if j.fc != nil {171 if j.fc != nil {
167 _ = j.fc.Close()172 _ = j.fc.Close()
@@ -171,7 +176,8 @@ func Run(cancel *Canceller, cfg Config) Result {
171 }176 }
172 }()177 }()
173 res := j.run()178 res := j.run()
174- cfg.Reporter.JobCompleted(res)179+ res.Error = j.mask(res.Error)
180+ j.cfg.Reporter.JobCompleted(res)
175 return res181 return res
176 }182 }
177 183
@@ -243,9 +249,6 @@ func (j *job) timeoutMinutes() int {
243 func (j *job) write(num int, line string) {249 func (j *job) write(num int, line string) {
244 j.mu.Lock()250 j.mu.Lock()
245 defer j.mu.Unlock()251 defer j.mu.Unlock()
246- for _, m := range j.masks {
247- line = strings.ReplaceAll(line, m, "***")
248- }
249 j.cfg.Reporter.Log(num, j.now(), line)252 j.cfg.Reporter.Log(num, j.now(), line)
250 }253 }
251 254
@@ -258,6 +261,7 @@ func (j *job) out(num int, text string) {
258 261
259 // tagged writes tag+message; only the first line carries the tag.262 // tagged writes tag+message; only the first line carries the tag.
260 func (j *job) tagged(num int, tag, msg string) {263 func (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
261 if len(msg) > maxIssueMessageLength && tag != "" {265 if len(msg) > maxIssueMessageLength && tag != "" {
262 msg = msg[:maxIssueMessageLength]266 msg = msg[:maxIssueMessageLength]
263 }267 }
@@ -371,7 +375,7 @@ func (j *job) setUp() ([]*workflow.Step, error) {
371 state.WithLayout(layout)375 state.WithLayout(layout)
372 }376 }
373 j.state = state377 j.state = state
374- j.masks = state.Secrets()378+ j.masker.Store(newMasker(state.Secrets()...))
375 379
376 // Features that arrive in later phases fail the job with a clear message.380 // Features that arrive in later phases fail the job with a clear message.
377 switch {381 switch {
@@ -543,6 +547,11 @@ func (j *job) completeJob(num int) map[string]string {
543 j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))547 j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key))
544 continue548 continue
545 }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+ }
546 // ermite refuses outputs above 1 MiB in total (runner.proto,555 // ermite refuses outputs above 1 MiB in total (runner.proto,
547 // CompleteJobRequest.outputs), and they would not fit a frame of the556 // CompleteJobRequest.outputs), and they would not fit a frame of the
548 // stream either: the job fails without outputs.557 // stream either: the job fails without outputs.
A internal/executor/masker.go +80 -0
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "slices"
7+ "strings"
8+ "time"
9+)
10+
11+// masker replaces secret values by *** in text that leaves the executor
12+// (HostContext.SecretMasker). A value is masked as is and in the forms the
13+// official runner registers for it (HostContext.cs:103-113, ValueEncoders).
14+// Longer forms are replaced first.
15+type masker struct{ values []string }
16+
17+const maskedValue = "***"
18+
19+func newMasker(secrets ...string) *masker {
20+ seen := map[string]bool{}
21+ var vals []string
22+ add := func(v string) {
23+ if v != "" && !seen[v] {
24+ seen[v] = true
25+ vals = append(vals, v)
26+ }
27+ }
28+ for _, s := range secrets {
29+ add(s)
30+ for _, e := range secretEncodings(s) {
31+ add(e)
32+ }
33+ }
34+ slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) })
35+ return &masker{values: vals}
36+}
37+
38+// secretEncodings are the other forms of v that the runner masks.
39+func secretEncodings(string) []string { return nil }
40+
41+// mask returns s with every secret replaced.
42+func (m *masker) mask(s string) string {
43+ if m == nil {
44+ return s
45+ }
46+ for _, v := range m.values {
47+ if strings.Contains(s, v) {
48+ s = strings.ReplaceAll(s, v, maskedValue)
49+ }
50+ }
51+ return s
52+}
53+
54+// has reports whether s contains a secret.
55+func (m *masker) has(s string) bool { return m != nil && m.mask(s) != s }
56+
57+// maskReporter masks everything the executor reports: the log lines, the names
58+// of the steps and the error of the job. The outputs are checked where they are
59+// evaluated (a job output with a secret is dropped).
60+type maskReporter struct {
61+ j *job
62+ next Reporter
63+}
64+
65+func (r *maskReporter) StepStarted(n int, name string, at time.Time) {
66+ r.next.StepStarted(n, r.j.mask(name), at)
67+}
68+
69+func (r *maskReporter) Log(n int, at time.Time, line string) {
70+ r.next.Log(n, at, r.j.mask(line))
71+}
72+
73+func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) {
74+ r.next.StepCompleted(n, r.j.mask(name), c, at)
75+}
76+
77+func (r *maskReporter) JobCompleted(res Result) {
78+ res.Error = r.j.mask(res.Error)
79+ r.next.JobCompleted(res)
80+}
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "slices"
7+ "strings"
8+ "time"
9+)
10+
11+// masker replaces secret values by *** in text that leaves the executor
12+// (HostContext.SecretMasker). A value is masked as is and in the forms the
13+// official runner registers for it (HostContext.cs:103-113, ValueEncoders).
14+// Longer forms are replaced first.
15+type masker struct{ values []string }
16+
17+const maskedValue = "***"
18+
19+func newMasker(secrets ...string) *masker {
20+ seen := map[string]bool{}
21+ var vals []string
22+ add := func(v string) {
23+ if v != "" && !seen[v] {
24+ seen[v] = true
25+ vals = append(vals, v)
26+ }
27+ }
28+ for _, s := range secrets {
29+ add(s)
30+ for _, e := range secretEncodings(s) {
31+ add(e)
32+ }
33+ }
34+ slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) })
35+ return &masker{values: vals}
36+}
37+
38+// secretEncodings are the other forms of v that the runner masks.
39+func secretEncodings(string) []string { return nil }
40+
41+// mask returns s with every secret replaced.
42+func (m *masker) mask(s string) string {
43+ if m == nil {
44+ return s
45+ }
46+ for _, v := range m.values {
47+ if strings.Contains(s, v) {
48+ s = strings.ReplaceAll(s, v, maskedValue)
49+ }
50+ }
51+ return s
52+}
53+
54+// has reports whether s contains a secret.
55+func (m *masker) has(s string) bool { return m != nil && m.mask(s) != s }
56+
57+// maskReporter masks everything the executor reports: the log lines, the names
58+// of the steps and the error of the job. The outputs are checked where they are
59+// evaluated (a job output with a secret is dropped).
60+type maskReporter struct {
61+ j *job
62+ next Reporter
63+}
64+
65+func (r *maskReporter) StepStarted(n int, name string, at time.Time) {
66+ r.next.StepStarted(n, r.j.mask(name), at)
67+}
68+
69+func (r *maskReporter) Log(n int, at time.Time, line string) {
70+ r.next.Log(n, at, r.j.mask(line))
71+}
72+
73+func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) {
74+ r.next.StepCompleted(n, r.j.mask(name), c, at)
75+}
76+
77+func (r *maskReporter) JobCompleted(res Result) {
78+ res.Error = r.j.mask(res.Error)
79+ r.next.JobCompleted(res)
80+}
A internal/executor/masker_test.go +123 -0
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
11+const testToken = "ghs_testtoken123"
12+
13+func tokenContexts() string {
14+ return strings.Replace(testGitHubContext("main"), `"github":{`, `"github":{"token":"`+testToken+`",`, 1)
15+}
16+
17+// The name of a step that holds the token is masked before it reaches the
18+// server (StepStarted and StepCompleted).
19+func TestStepNameIsMasked(t *testing.T) {
20+ needTools(t, "bash")
21+ yaml := `on: push
22+jobs:
23+ main:
24+ runs-on: ubuntu-24.04
25+ steps:
26+ - name: leak ${{ github.token }}
27+ run: echo hi
28+ - name: leak2 ${{ secrets.GITHUB_TOKEN }} ${{ env.X }}
29+ env:
30+ X: x
31+ run: echo hi
32+`
33+ rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
34+ if res.Conclusion != ConclusionSuccess {
35+ t.Fatalf("%+v", res)
36+ }
37+ for _, s := range rec.steps {
38+ if strings.Contains(s.Name, testToken) {
39+ t.Errorf("step name %q holds the token", s.Name)
40+ }
41+ }
42+ rec.step(t, "leak ***")
43+ rec.step(t, "leak2 *** x")
44+}
45+
46+// A job output that holds the token is dropped with GitHub's warning; the
47+// other outputs stay.
48+func TestJobOutputWithTokenIsDropped(t *testing.T) {
49+ needTools(t, "bash")
50+ yaml := `on: push
51+jobs:
52+ main:
53+ runs-on: ubuntu-24.04
54+ outputs:
55+ leak: ${{ steps.s.outputs.v }}
56+ fine: ${{ steps.s.outputs.w }}
57+ steps:
58+ - id: s
59+ env:
60+ T: ${{ github.token }}
61+ run: |
62+ echo "v=prefix-$T-suffix" >> "$GITHUB_OUTPUT"
63+ echo "w=ok" >> "$GITHUB_OUTPUT"
64+`
65+ rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
66+ if res.Conclusion != ConclusionSuccess {
67+ t.Fatalf("%+v", res)
68+ }
69+ if _, has := res.Outputs["leak"]; has || res.Outputs["fine"] != "ok" {
70+ t.Errorf("outputs %v", res.Outputs)
71+ }
72+ for _, v := range res.Outputs {
73+ if strings.Contains(v, testToken) {
74+ t.Errorf("the token is in an output: %q", v)
75+ }
76+ }
77+ if log := logOf(rec.step(t, CompleteJobName)); !strings.Contains(log, "##[warning]Skip output 'leak' since it may contain secret.") {
78+ t.Errorf("warning missing:\n%s", log)
79+ }
80+}
81+
82+// Everything the executor reports goes through the masker: the names of the
83+// steps, the log, and the error of the job (no real run puts a token there, so
84+// the reporter is exercised directly).
85+func TestMaskReporter(t *testing.T) {
86+ rec := newRecorder()
87+ j := &job{}
88+ j.masker.Store(newMasker(testToken))
89+ r := &maskReporter{j: j, next: rec}
90+ r.StepStarted(1, "name "+testToken, time.Now())
91+ r.Log(1, time.Now(), "line "+testToken)
92+ r.StepCompleted(1, "name "+testToken, ConclusionSuccess, time.Now())
93+ r.JobCompleted(Result{Conclusion: ConclusionFailure, Error: "boom " + testToken})
94+ if s := rec.steps[0]; s.Name != "name ***" || s.Log[0] != "line ***" {
95+ t.Errorf("%+v", s)
96+ }
97+ if rec.result.Error != "boom ***" {
98+ t.Errorf("error %q", rec.result.Error)
99+ }
100+}
101+
102+// The token is masked before a message is cut at 4096 bytes: a token split by
103+// the cut would otherwise leave its start in the line.
104+func TestMaskBeforeTruncation(t *testing.T) {
105+ needTools(t, "bash")
106+ pad := strings.Repeat("a", maxIssueMessageLength-5)
107+ yaml := `on: push
108+jobs:
109+ main:
110+ runs-on: ubuntu-24.04
111+ steps:
112+ - run: |
113+ echo "::error::` + pad + `${{ github.token }}"
114+`
115+ rec, _ := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
116+ for _, s := range rec.steps {
117+ for _, l := range s.Log {
118+ if strings.Contains(l, "ghs_") {
119+ t.Errorf("a piece of the token leaked: %q", l[len(l)-20:])
120+ }
121+ }
122+ }
123+}
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
11+const testToken = "ghs_testtoken123"
12+
13+func tokenContexts() string {
14+ return strings.Replace(testGitHubContext("main"), `"github":{`, `"github":{"token":"`+testToken+`",`, 1)
15+}
16+
17+// The name of a step that holds the token is masked before it reaches the
18+// server (StepStarted and StepCompleted).
19+func TestStepNameIsMasked(t *testing.T) {
20+ needTools(t, "bash")
21+ yaml := `on: push
22+jobs:
23+ main:
24+ runs-on: ubuntu-24.04
25+ steps:
26+ - name: leak ${{ github.token }}
27+ run: echo hi
28+ - name: leak2 ${{ secrets.GITHUB_TOKEN }} ${{ env.X }}
29+ env:
30+ X: x
31+ run: echo hi
32+`
33+ rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
34+ if res.Conclusion != ConclusionSuccess {
35+ t.Fatalf("%+v", res)
36+ }
37+ for _, s := range rec.steps {
38+ if strings.Contains(s.Name, testToken) {
39+ t.Errorf("step name %q holds the token", s.Name)
40+ }
41+ }
42+ rec.step(t, "leak ***")
43+ rec.step(t, "leak2 *** x")
44+}
45+
46+// A job output that holds the token is dropped with GitHub's warning; the
47+// other outputs stay.
48+func TestJobOutputWithTokenIsDropped(t *testing.T) {
49+ needTools(t, "bash")
50+ yaml := `on: push
51+jobs:
52+ main:
53+ runs-on: ubuntu-24.04
54+ outputs:
55+ leak: ${{ steps.s.outputs.v }}
56+ fine: ${{ steps.s.outputs.w }}
57+ steps:
58+ - id: s
59+ env:
60+ T: ${{ github.token }}
61+ run: |
62+ echo "v=prefix-$T-suffix" >> "$GITHUB_OUTPUT"
63+ echo "w=ok" >> "$GITHUB_OUTPUT"
64+`
65+ rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
66+ if res.Conclusion != ConclusionSuccess {
67+ t.Fatalf("%+v", res)
68+ }
69+ if _, has := res.Outputs["leak"]; has || res.Outputs["fine"] != "ok" {
70+ t.Errorf("outputs %v", res.Outputs)
71+ }
72+ for _, v := range res.Outputs {
73+ if strings.Contains(v, testToken) {
74+ t.Errorf("the token is in an output: %q", v)
75+ }
76+ }
77+ if log := logOf(rec.step(t, CompleteJobName)); !strings.Contains(log, "##[warning]Skip output 'leak' since it may contain secret.") {
78+ t.Errorf("warning missing:\n%s", log)
79+ }
80+}
81+
82+// Everything the executor reports goes through the masker: the names of the
83+// steps, the log, and the error of the job (no real run puts a token there, so
84+// the reporter is exercised directly).
85+func TestMaskReporter(t *testing.T) {
86+ rec := newRecorder()
87+ j := &job{}
88+ j.masker.Store(newMasker(testToken))
89+ r := &maskReporter{j: j, next: rec}
90+ r.StepStarted(1, "name "+testToken, time.Now())
91+ r.Log(1, time.Now(), "line "+testToken)
92+ r.StepCompleted(1, "name "+testToken, ConclusionSuccess, time.Now())
93+ r.JobCompleted(Result{Conclusion: ConclusionFailure, Error: "boom " + testToken})
94+ if s := rec.steps[0]; s.Name != "name ***" || s.Log[0] != "line ***" {
95+ t.Errorf("%+v", s)
96+ }
97+ if rec.result.Error != "boom ***" {
98+ t.Errorf("error %q", rec.result.Error)
99+ }
100+}
101+
102+// The token is masked before a message is cut at 4096 bytes: a token split by
103+// the cut would otherwise leave its start in the line.
104+func TestMaskBeforeTruncation(t *testing.T) {
105+ needTools(t, "bash")
106+ pad := strings.Repeat("a", maxIssueMessageLength-5)
107+ yaml := `on: push
108+jobs:
109+ main:
110+ runs-on: ubuntu-24.04
111+ steps:
112+ - run: |
113+ echo "::error::` + pad + `${{ github.token }}"
114+`
115+ rec, _ := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
116+ for _, s := range rec.steps {
117+ for _, l := range s.Log {
118+ if strings.Contains(l, "ghs_") {
119+ t.Errorf("a piece of the token leaked: %q", l[len(l)-20:])
120+ }
121+ }
122+ }
123+}

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