detached 40f32c5

test(executor): container lifecycle, cleanup after failure, cancel and timeouts, credentials

commit
40f32c558663922d6c7fa53a231f69f52091b5a2
author
brice · · 2026-10-14 05:53 UTC
parent
4d5e5f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +200 -3
M internal/executor/containers_fake_test.go +20 -2
@@ -49,7 +49,8 @@ logs) echo "service log of $3" ;;
4949 rm) echo "$3" ;;
5050 exec) [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH"
5151 [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
52- [ -f $D/exec_out ] && cat $D/exec_out ;;
52+ [ -f $D/exec_out ] && cat $D/exec_out
53+ if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;;
5354 esac
5455 exit 0
5556 `
@@ -86,9 +87,14 @@ func (f *fakeCtr) verbs(t *testing.T) []string {
8687
8788 // ctrRun runs a workflow whose job has the given container/services YAML.
8889 func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
90+ t.Helper()
91+ return ctrRunC(t, f, nil, jobYAML, steps, tweak)
92+}
93+
94+func ctrRunC(t *testing.T, f *fakeCtr, cancel *Canceller, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
8995 t.Helper()
9096 needTools(t, "bash")
91- return runLocal(t, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {
97+ return runLocalC(t, cancel, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {
9298 c.Actions.DockerPath = f.bin
9399 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
94100 c.Actions.ExternalsDir = fakeNode(t)
@@ -105,3 +111,15 @@ func joinCalls(cs [][]string) string {
105111 }
106112 return b.String()
107113 }
114+
115+// concl is the conclusion of the step called name.
116+func (r *recorder) concl(name string) Conclusion {
117+ r.mu.Lock()
118+ defer r.mu.Unlock()
119+ for _, s := range r.steps {
120+ if s.Name == name {
121+ return s.Conclusion
122+ }
123+ }
124+ return ""
125+}
@@ -49,7 +49,8 @@ logs) echo "service log of $3" ;;
49 rm) echo "$3" ;;49 rm) echo "$3" ;;
50 exec) [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH"50 exec) [ -f $D/exec_env ] && echo "ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE RUNNER_TEMP=$RUNNER_TEMP HOME=${HOME:-<unset>} GITHUB_OUTPUT=$GITHUB_OUTPUT GITHUB_EVENT_PATH=$GITHUB_EVENT_PATH"
51 [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }51 [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
52- [ -f $D/exec_out ] && cat $D/exec_out ;;52+ [ -f $D/exec_out ] && cat $D/exec_out
53+ if [ -f $D/exec_exit ]; then n=$(cat $D/exec_exit); [ "$n" != 0 ] && echo 0 > $D/exec_exit && exit $n; fi ;;
53 esac54 esac
54 exit 055 exit 0
55 `56 `
@@ -86,9 +87,14 @@ func (f *fakeCtr) verbs(t *testing.T) []string {
86 87
87 // ctrRun runs a workflow whose job has the given container/services YAML.88 // ctrRun runs a workflow whose job has the given container/services YAML.
88 func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {89 func ctrRun(t *testing.T, f *fakeCtr, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
90+ t.Helper()
91+ return ctrRunC(t, f, nil, jobYAML, steps, tweak)
92+}
93+
94+func ctrRunC(t *testing.T, f *fakeCtr, cancel *Canceller, jobYAML, steps string, tweak func(*Config)) (*recorder, Result) {
89 t.Helper()95 t.Helper()
90 needTools(t, "bash")96 needTools(t, "bash")
91- return runLocal(t, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {97+ return runLocalC(t, cancel, wfHead+jobYAML+" steps:\n"+steps, "j", func(c *Config) {
92 c.Actions.DockerPath = f.bin98 c.Actions.DockerPath = f.bin
93 c.Actions.Backoff = func() time.Duration { return time.Millisecond }99 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
94 c.Actions.ExternalsDir = fakeNode(t)100 c.Actions.ExternalsDir = fakeNode(t)
@@ -105,3 +111,15 @@ func joinCalls(cs [][]string) string {
105 }111 }
106 return b.String()112 return b.String()
107 }113 }
114+
115+// concl is the conclusion of the step called name.
116+func (r *recorder) concl(name string) Conclusion {
117+ r.mu.Lock()
118+ defer r.mu.Unlock()
119+ for _, s := range r.steps {
120+ if s.Name == name {
121+ return s.Conclusion
122+ }
123+ }
124+ return ""
125+}
A internal/executor/containers_life_test.go +173 -0
@@ -0,0 +1,173 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+const ctrJob = " container: ubuntu:24.04\n"
14+
15+func TestPullRetriesThenFailsAndStillCleansUp(t *testing.T) {
16+ f := newFakeCtr(t)
17+ f.set(t, "pull_fail", "9")
18+ rec, res := ctrRun(t, f, " container: no-such-image:nope\n", " - name: never\n run: echo never\n", nil)
19+ if res.Conclusion != ConclusionFailure {
20+ t.Fatalf("%+v", res)
21+ }
22+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|never|Stop containers|Complete job" {
23+ t.Fatalf("steps %s", got)
24+ }
25+ if c := rec.concl("never"); c != ConclusionSkipped {
26+ t.Errorf("never: %s", c)
27+ }
28+ log := logOf(rec.step(t, "Initialize containers"))
29+ if n := strings.Count(log, "##[command]"+f.bin+" pull no-such-image:nope"); n != 3 {
30+ t.Errorf("%d pulls:\n%s", n, log)
31+ }
32+ for _, w := range []string{"##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.", "##[error]Docker pull failed with exit code 1"} {
33+ if !strings.Contains(log, w) {
34+ t.Errorf("lacks %q:\n%s", w, log)
35+ }
36+ }
37+ // Nothing was created: the stop step has only the network to remove.
38+ stop := logOf(rec.step(t, "Stop containers"))
39+ if strings.Contains(stop, "Stop and remove container") || !strings.Contains(stop, "Remove container network: github_network_") {
40+ t.Errorf("stop:\n%s", stop)
41+ }
42+}
43+
44+func TestCreateFailureMessage(t *testing.T) {
45+ f := newFakeCtr(t)
46+ f.set(t, "create_fail", "1")
47+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --not-a-docker-flag\n", " - run: echo never\n", nil)
48+ if res.Conclusion != ConclusionFailure {
49+ t.Fatalf("%+v", res)
50+ }
51+ log := logOf(rec.step(t, "Initialize containers"))
52+ if !strings.Contains(log, "##[error]Exit code 125 returned from process: file name '"+f.bin+"', arguments 'create --name ") ||
53+ !strings.Contains(log, " --not-a-docker-flag -e \"HOME=/github/home\"") {
54+ t.Errorf("log:\n%s", log)
55+ }
56+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network") {
57+ t.Errorf("stop:\n%s", got)
58+ }
59+}
60+
61+func TestUnhealthyServiceFailsInit(t *testing.T) {
62+ f := newFakeCtr(t)
63+ f.set(t, "health", "starting\nstarting\nunhealthy\n")
64+ rec, res := ctrRun(t, f, " services:\n broken:\n image: redis:7\n", " - run: echo never\n", nil)
65+ if res.Conclusion != ConclusionFailure {
66+ t.Fatalf("%+v", res)
67+ }
68+ log := logOf(rec.step(t, "Initialize containers"))
69+ for _, w := range []string{
70+ "##[group]Service container broken failed.\n##[command]" + f.bin + " logs --details ",
71+ "##[error]Failed to initialize container redis:7\n##[endgroup]\n##[error]One or more containers failed to start.",
72+ } {
73+ if !strings.Contains(log, w) {
74+ t.Errorf("lacks %q:\n%s", w, log)
75+ }
76+ }
77+ // A service that failed to initialise has its logs printed once, not twice.
78+ stop := logOf(rec.step(t, "Stop containers"))
79+ if strings.Contains(stop, "Print service container logs") || !strings.Contains(stop, "Stop and remove container: ") {
80+ t.Errorf("stop:\n%s", stop)
81+ }
82+}
83+
84+func TestRegistryCredentialsAreMaskedAndRemoved(t *testing.T) {
85+ f := newFakeCtr(t)
86+ var temp string
87+ rec, res := ctrRun(t, f, " container:\n image: ghcr.io/o/img:1\n credentials:\n username: bob\n password: hunter2-secret\n", " - run: echo ok\n", func(c *Config) { temp = c.Layout.Temp })
88+ if res.Conclusion != ConclusionSuccess {
89+ t.Fatalf("%+v", res)
90+ }
91+ all := ""
92+ for _, s := range rec.steps {
93+ all += logOf(s)
94+ }
95+ if strings.Contains(all, "hunter2-secret") {
96+ t.Errorf("password in the log")
97+ }
98+ if b, _ := os.ReadFile(filepath.Join(f.dir, "login_stdin")); string(b) != "hunter2-secret\n" {
99+ t.Errorf("password not passed through stdin: %q", b)
100+ }
101+ log := logOf(rec.step(t, "Initialize containers"))
102+ if !strings.Contains(log, " login ghcr.io -u bob --password-stdin") || !strings.Contains(log, " pull ghcr.io/o/img:1") || !strings.Contains(log, " --config ") {
103+ t.Errorf("log:\n%s", log)
104+ }
105+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
106+ t.Errorf("config left behind: %v", m)
107+ }
108+}
109+
110+func TestCancelDuringStepStillStopsContainers(t *testing.T) {
111+ f := newFakeCtr(t)
112+ f.set(t, "hang_exec", "1")
113+ cancel := NewCanceller()
114+ go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelUser) }()
115+ rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil)
116+ if res.Conclusion != ConclusionCancelled {
117+ t.Fatalf("%+v %v", res, rec.names())
118+ }
119+ stop := rec.step(t, "Stop containers")
120+ if got := logOf(stop); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
121+ t.Errorf("stop:\n%s", got)
122+ }
123+ if c := rec.concl("Stop containers"); c != ConclusionSuccess {
124+ t.Errorf("Stop containers: %s", c)
125+ }
126+ if v := f.verbs(t); v[len(v)-1] != "network rm" || v[len(v)-2] != "rm" {
127+ t.Errorf("last calls %v", v)
128+ }
129+}
130+
131+func TestStepTimeoutStopsContainersAndIsAFailure(t *testing.T) {
132+ f := newFakeCtr(t)
133+ f.set(t, "hang_exec", "1")
134+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n timeout-minutes: 1\n run: sleep 60\n", func(c *Config) {
135+ c.StepTimeoutSkew = -59*time.Second - 700*time.Millisecond
136+ })
137+ if res.Conclusion != ConclusionFailure {
138+ t.Fatalf("%+v %v", res, rec.names())
139+ }
140+ if got := logOf(rec.step(t, "long")); !strings.Contains(got, "##[error]The action 'long' has timed out after 1 minutes.") {
141+ t.Errorf("long:\n%s", got)
142+ }
143+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: ") {
144+ t.Errorf("stop:\n%s", got)
145+ }
146+}
147+
148+func TestJobTimeoutStopsContainers(t *testing.T) {
149+ f := newFakeCtr(t)
150+ f.set(t, "hang_exec", "1")
151+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n run: sleep 60\n", func(c *Config) { c.Deadline = 700 * time.Millisecond })
152+ if res.Conclusion != ConclusionTimedOut {
153+ t.Fatalf("%+v %v", res, rec.names())
154+ }
155+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
156+ t.Errorf("stop:\n%s", got)
157+ }
158+}
159+
160+func TestStopContainersRunsAfterAFailedStepAndPostStepsRunInTheContainer(t *testing.T) {
161+ f := newFakeCtr(t)
162+ f.set(t, "exec_exit", "1") // the first exec fails
163+ rec, res := ctrRun(t, f, ctrJob, " - name: bad\n run: exit 1\n - name: after\n if: always()\n run: echo after\n", nil)
164+ if res.Conclusion != ConclusionFailure {
165+ t.Fatalf("%+v", res)
166+ }
167+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|bad|after|Stop containers|Complete job" {
168+ t.Errorf("steps %s", got)
169+ }
170+ if len(f.execCalls(t)) != 2 {
171+ t.Errorf("both steps run through docker exec: %v", f.execCalls(t))
172+ }
173+}
@@ -0,0 +1,173 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+const ctrJob = " container: ubuntu:24.04\n"
14+
15+func TestPullRetriesThenFailsAndStillCleansUp(t *testing.T) {
16+ f := newFakeCtr(t)
17+ f.set(t, "pull_fail", "9")
18+ rec, res := ctrRun(t, f, " container: no-such-image:nope\n", " - name: never\n run: echo never\n", nil)
19+ if res.Conclusion != ConclusionFailure {
20+ t.Fatalf("%+v", res)
21+ }
22+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|never|Stop containers|Complete job" {
23+ t.Fatalf("steps %s", got)
24+ }
25+ if c := rec.concl("never"); c != ConclusionSkipped {
26+ t.Errorf("never: %s", c)
27+ }
28+ log := logOf(rec.step(t, "Initialize containers"))
29+ if n := strings.Count(log, "##[command]"+f.bin+" pull no-such-image:nope"); n != 3 {
30+ t.Errorf("%d pulls:\n%s", n, log)
31+ }
32+ for _, w := range []string{"##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.", "##[error]Docker pull failed with exit code 1"} {
33+ if !strings.Contains(log, w) {
34+ t.Errorf("lacks %q:\n%s", w, log)
35+ }
36+ }
37+ // Nothing was created: the stop step has only the network to remove.
38+ stop := logOf(rec.step(t, "Stop containers"))
39+ if strings.Contains(stop, "Stop and remove container") || !strings.Contains(stop, "Remove container network: github_network_") {
40+ t.Errorf("stop:\n%s", stop)
41+ }
42+}
43+
44+func TestCreateFailureMessage(t *testing.T) {
45+ f := newFakeCtr(t)
46+ f.set(t, "create_fail", "1")
47+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n options: --not-a-docker-flag\n", " - run: echo never\n", nil)
48+ if res.Conclusion != ConclusionFailure {
49+ t.Fatalf("%+v", res)
50+ }
51+ log := logOf(rec.step(t, "Initialize containers"))
52+ if !strings.Contains(log, "##[error]Exit code 125 returned from process: file name '"+f.bin+"', arguments 'create --name ") ||
53+ !strings.Contains(log, " --not-a-docker-flag -e \"HOME=/github/home\"") {
54+ t.Errorf("log:\n%s", log)
55+ }
56+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network") {
57+ t.Errorf("stop:\n%s", got)
58+ }
59+}
60+
61+func TestUnhealthyServiceFailsInit(t *testing.T) {
62+ f := newFakeCtr(t)
63+ f.set(t, "health", "starting\nstarting\nunhealthy\n")
64+ rec, res := ctrRun(t, f, " services:\n broken:\n image: redis:7\n", " - run: echo never\n", nil)
65+ if res.Conclusion != ConclusionFailure {
66+ t.Fatalf("%+v", res)
67+ }
68+ log := logOf(rec.step(t, "Initialize containers"))
69+ for _, w := range []string{
70+ "##[group]Service container broken failed.\n##[command]" + f.bin + " logs --details ",
71+ "##[error]Failed to initialize container redis:7\n##[endgroup]\n##[error]One or more containers failed to start.",
72+ } {
73+ if !strings.Contains(log, w) {
74+ t.Errorf("lacks %q:\n%s", w, log)
75+ }
76+ }
77+ // A service that failed to initialise has its logs printed once, not twice.
78+ stop := logOf(rec.step(t, "Stop containers"))
79+ if strings.Contains(stop, "Print service container logs") || !strings.Contains(stop, "Stop and remove container: ") {
80+ t.Errorf("stop:\n%s", stop)
81+ }
82+}
83+
84+func TestRegistryCredentialsAreMaskedAndRemoved(t *testing.T) {
85+ f := newFakeCtr(t)
86+ var temp string
87+ rec, res := ctrRun(t, f, " container:\n image: ghcr.io/o/img:1\n credentials:\n username: bob\n password: hunter2-secret\n", " - run: echo ok\n", func(c *Config) { temp = c.Layout.Temp })
88+ if res.Conclusion != ConclusionSuccess {
89+ t.Fatalf("%+v", res)
90+ }
91+ all := ""
92+ for _, s := range rec.steps {
93+ all += logOf(s)
94+ }
95+ if strings.Contains(all, "hunter2-secret") {
96+ t.Errorf("password in the log")
97+ }
98+ if b, _ := os.ReadFile(filepath.Join(f.dir, "login_stdin")); string(b) != "hunter2-secret\n" {
99+ t.Errorf("password not passed through stdin: %q", b)
100+ }
101+ log := logOf(rec.step(t, "Initialize containers"))
102+ if !strings.Contains(log, " login ghcr.io -u bob --password-stdin") || !strings.Contains(log, " pull ghcr.io/o/img:1") || !strings.Contains(log, " --config ") {
103+ t.Errorf("log:\n%s", log)
104+ }
105+ if m, _ := filepath.Glob(filepath.Join(temp, ".docker_*")); len(m) != 0 {
106+ t.Errorf("config left behind: %v", m)
107+ }
108+}
109+
110+func TestCancelDuringStepStillStopsContainers(t *testing.T) {
111+ f := newFakeCtr(t)
112+ f.set(t, "hang_exec", "1")
113+ cancel := NewCanceller()
114+ go func() { time.Sleep(600 * time.Millisecond); cancel.Cancel(CancelUser) }()
115+ rec, res := ctrRunC(t, f, cancel, ctrJob, " - name: long\n run: sleep 60\n", nil)
116+ if res.Conclusion != ConclusionCancelled {
117+ t.Fatalf("%+v %v", res, rec.names())
118+ }
119+ stop := rec.step(t, "Stop containers")
120+ if got := logOf(stop); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
121+ t.Errorf("stop:\n%s", got)
122+ }
123+ if c := rec.concl("Stop containers"); c != ConclusionSuccess {
124+ t.Errorf("Stop containers: %s", c)
125+ }
126+ if v := f.verbs(t); v[len(v)-1] != "network rm" || v[len(v)-2] != "rm" {
127+ t.Errorf("last calls %v", v)
128+ }
129+}
130+
131+func TestStepTimeoutStopsContainersAndIsAFailure(t *testing.T) {
132+ f := newFakeCtr(t)
133+ f.set(t, "hang_exec", "1")
134+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n timeout-minutes: 1\n run: sleep 60\n", func(c *Config) {
135+ c.StepTimeoutSkew = -59*time.Second - 700*time.Millisecond
136+ })
137+ if res.Conclusion != ConclusionFailure {
138+ t.Fatalf("%+v %v", res, rec.names())
139+ }
140+ if got := logOf(rec.step(t, "long")); !strings.Contains(got, "##[error]The action 'long' has timed out after 1 minutes.") {
141+ t.Errorf("long:\n%s", got)
142+ }
143+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Remove container network: ") {
144+ t.Errorf("stop:\n%s", got)
145+ }
146+}
147+
148+func TestJobTimeoutStopsContainers(t *testing.T) {
149+ f := newFakeCtr(t)
150+ f.set(t, "hang_exec", "1")
151+ rec, res := ctrRun(t, f, ctrJob, " - name: long\n run: sleep 60\n", func(c *Config) { c.Deadline = 700 * time.Millisecond })
152+ if res.Conclusion != ConclusionTimedOut {
153+ t.Fatalf("%+v %v", res, rec.names())
154+ }
155+ if got := logOf(rec.step(t, "Stop containers")); !strings.Contains(got, "Stop and remove container: ") || !strings.Contains(got, "Remove container network: ") {
156+ t.Errorf("stop:\n%s", got)
157+ }
158+}
159+
160+func TestStopContainersRunsAfterAFailedStepAndPostStepsRunInTheContainer(t *testing.T) {
161+ f := newFakeCtr(t)
162+ f.set(t, "exec_exit", "1") // the first exec fails
163+ rec, res := ctrRun(t, f, ctrJob, " - name: bad\n run: exit 1\n - name: after\n if: always()\n run: echo after\n", nil)
164+ if res.Conclusion != ConclusionFailure {
165+ t.Fatalf("%+v", res)
166+ }
167+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|bad|after|Stop containers|Complete job" {
168+ t.Errorf("steps %s", got)
169+ }
170+ if len(f.execCalls(t)) != 2 {
171+ t.Errorf("both steps run through docker exec: %v", f.execCalls(t))
172+ }
173+}
M internal/executor/corpus_test.go +7 -1
@@ -155,6 +155,12 @@ func testGitHubContext(job string) string {
155155 // runLocal runs one job of a workflow file on this machine under a temporary
156156 // home. The environment is minimal and fixed.
157157 func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158+ t.Helper()
159+ return runLocalC(t, nil, yaml, jobKey, tweak)
160+}
161+
162+// runLocalC is runLocal with a canceller.
163+func runLocalC(t *testing.T, cancel *Canceller, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158164 t.Helper()
159165 home := t.TempDir()
160166 layout := NewLayout(home, "hello", filepath.Join(home, "toolcache"))
@@ -169,7 +175,7 @@ func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder
169175 if tweak != nil {
170176 tweak(&cfg)
171177 }
172- res := Run(nil, cfg)
178+ res := Run(cancel, cfg)
173179 return rec, res
174180 }
175181
@@ -155,6 +155,12 @@ func testGitHubContext(job string) string {
155 // runLocal runs one job of a workflow file on this machine under a temporary155 // runLocal runs one job of a workflow file on this machine under a temporary
156 // home. The environment is minimal and fixed.156 // home. The environment is minimal and fixed.
157 func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {157 func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158+ t.Helper()
159+ return runLocalC(t, nil, yaml, jobKey, tweak)
160+}
161+
162+// runLocalC is runLocal with a canceller.
163+func runLocalC(t *testing.T, cancel *Canceller, yaml, jobKey string, tweak func(*Config)) (*recorder, Result) {
158 t.Helper()164 t.Helper()
159 home := t.TempDir()165 home := t.TempDir()
160 layout := NewLayout(home, "hello", filepath.Join(home, "toolcache"))166 layout := NewLayout(home, "hello", filepath.Join(home, "toolcache"))
@@ -169,7 +175,7 @@ func runLocal(t *testing.T, yaml, jobKey string, tweak func(*Config)) (*recorder
169 if tweak != nil {175 if tweak != nil {
170 tweak(&cfg)176 tweak(&cfg)
171 }177 }
172- res := Run(nil, cfg)178+ res := Run(cancel, cfg)
173 return rec, res179 return rec, res
174 }180 }
175 181

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