detached 85978ae

feat(executor): run steps through docker exec in the job container

commit
85978ae8f73915fad1c44a0d672d2ac49ddd2436
author
brice · · 2026-10-14 05:50 UTC
parent
596a6c3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +232 -3
A internal/executor/containers_exec.go +97 -0
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+)
8+
9+// ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of
10+// a job that has a container run through `docker exec` in it.
11+
12+// stepContainer is the job container when steps run in it, else nil.
13+func (j *job) stepContainer() *container {
14+ if j.cs == nil {
15+ return nil
16+ }
17+ return j.cs.job
18+}
19+
20+// containerReady is the check of ActionRunner.cs:157-164: the container must
21+// exist. After a failed "Initialize containers" an always() step gets the
22+// runner's error.
23+func (j *job) containerReady() error {
24+ if c := j.stepContainer(); c != nil && c.id == "" {
25+ return ghErrorf("Value cannot be null. (Parameter 'ContainerId')")
26+ }
27+ return nil
28+}
29+
30+// hostEnv is the environment a handler gives its process: the step's
31+// variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*)
32+// (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on.
33+func (j *job) handlerEnv(stepEnv *ordered) *ordered {
34+ env := stepEnv.Clone()
35+ j.mu.Lock()
36+ rt := j.state.RuntimeEnv()
37+ j.mu.Unlock()
38+ for _, k := range rt.Keys() {
39+ v, _ := rt.Get(k)
40+ env.Set(k, v)
41+ }
42+ return env
43+}
44+
45+// execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246):
46+// `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id>
47+// <command> <arguments>`. The values of the variables are not on the command
48+// line: -e NAME takes them from the environment of docker, which gets them
49+// with the host paths translated to the container's (:248-254). args are the
50+// arguments of the command, already split.
51+func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) {
52+ argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir}
53+ cenv := newOrdered()
54+ for _, k := range env.Keys() {
55+ v, _ := env.Get(k)
56+ argv = append(argv, "-e", k)
57+ cenv.Set(k, c.translateToContainer(v))
58+ }
59+ j.mu.Lock()
60+ prepend := j.state.Prepend()
61+ j.mu.Unlock()
62+ if len(prepend) > 0 {
63+ rev := make([]string, len(prepend))
64+ for i, p := range prepend {
65+ rev[len(prepend)-1-i] = p
66+ }
67+ full := strings.Join(rev, ":")
68+ if c.runtimePath != "" {
69+ full += ":" + c.runtimePath
70+ }
71+ argv = append(argv, "-e", "PATH="+full)
72+ }
73+ argv = append(argv, c.id, command)
74+ argv = append(argv, args...)
75+ return argv, j.dockerProcessEnv(cenv)
76+}
77+
78+// containerScript prepares a run step for the container: the script is
79+// written on the host in RUNNER_TEMP, which the container has at /__w/_temp,
80+// and the shell gets that path.
81+func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) {
82+ sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer)
83+ if err != nil {
84+ return nil, err
85+ }
86+ j.mu.Lock()
87+ workspace := j.state.GitHub("workspace")
88+ j.mu.Unlock()
89+ wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory))
90+ var rest []string
91+ if len(sc.Argv) > 1 {
92+ rest = sc.Argv[1:]
93+ }
94+ a, penv := j.execArgv(c, wd, j.handlerEnv(s.env), plan.CommandPath, rest)
95+ s.rawEnvList = penv
96+ return a, nil
97+}
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+)
8+
9+// ContainerStepHost (Runner.Worker/Handlers/StepHost.cs:102-296): the steps of
10+// a job that has a container run through `docker exec` in it.
11+
12+// stepContainer is the job container when steps run in it, else nil.
13+func (j *job) stepContainer() *container {
14+ if j.cs == nil {
15+ return nil
16+ }
17+ return j.cs.job
18+}
19+
20+// containerReady is the check of ActionRunner.cs:157-164: the container must
21+// exist. After a failed "Initialize containers" an always() step gets the
22+// runner's error.
23+func (j *job) containerReady() error {
24+ if c := j.stepContainer(); c != nil && c.id == "" {
25+ return ghErrorf("Value cannot be null. (Parameter 'ContainerId')")
26+ }
27+ return nil
28+}
29+
30+// hostEnv is the environment a handler gives its process: the step's
31+// variables then the runtime variables of the contexts (GITHUB_*, RUNNER_*)
32+// (ScriptHandler.cs:289-299). It is what `docker exec -e` passes on.
33+func (j *job) handlerEnv(stepEnv *ordered) *ordered {
34+ env := stepEnv.Clone()
35+ j.mu.Lock()
36+ rt := j.state.RuntimeEnv()
37+ j.mu.Unlock()
38+ for _, k := range rt.Keys() {
39+ v, _ := rt.Get(k)
40+ env.Set(k, v)
41+ }
42+ return env
43+}
44+
45+// execArgv is ContainerStepHost.ExecuteAsync (StepHost.cs:196-246):
46+// `docker exec -i --workdir <dir> -e NAME... [-e PATH="<prepend>:<PATH>"] <id>
47+// <command> <arguments>`. The values of the variables are not on the command
48+// line: -e NAME takes them from the environment of docker, which gets them
49+// with the host paths translated to the container's (:248-254). args are the
50+// arguments of the command, already split.
51+func (j *job) execArgv(c *container, workDir string, env *ordered, command string, args []string) (argv []string, procEnv []string) {
52+ argv = []string{j.dockerPath(), "exec", "-i", "--workdir", workDir}
53+ cenv := newOrdered()
54+ for _, k := range env.Keys() {
55+ v, _ := env.Get(k)
56+ argv = append(argv, "-e", k)
57+ cenv.Set(k, c.translateToContainer(v))
58+ }
59+ j.mu.Lock()
60+ prepend := j.state.Prepend()
61+ j.mu.Unlock()
62+ if len(prepend) > 0 {
63+ rev := make([]string, len(prepend))
64+ for i, p := range prepend {
65+ rev[len(prepend)-1-i] = p
66+ }
67+ full := strings.Join(rev, ":")
68+ if c.runtimePath != "" {
69+ full += ":" + c.runtimePath
70+ }
71+ argv = append(argv, "-e", "PATH="+full)
72+ }
73+ argv = append(argv, c.id, command)
74+ argv = append(argv, args...)
75+ return argv, j.dockerProcessEnv(cenv)
76+}
77+
78+// containerScript prepares a run step for the container: the script is
79+// written on the host in RUNNER_TEMP, which the container has at /__w/_temp,
80+// and the shell gets that path.
81+func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *stepRun) (argv []string, err error) {
82+ sc, err := WriteScriptFor(j.tempRoot, plan, in.script, c.translateToContainer)
83+ if err != nil {
84+ return nil, err
85+ }
86+ j.mu.Lock()
87+ workspace := j.state.GitHub("workspace")
88+ j.mu.Unlock()
89+ wd := c.resolvePath(ResolveWorkingDirectory(workspace, in.workingDirectory))
90+ var rest []string
91+ if len(sc.Argv) > 1 {
92+ rest = sc.Argv[1:]
93+ }
94+ a, penv := j.execArgv(c, wd, j.handlerEnv(s.env), plan.CommandPath, rest)
95+ s.rawEnvList = penv
96+ return a, nil
97+}
A internal/executor/containers_exec_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+// execCalls are the `docker exec` calls recorded by the fake.
11+func (f *fakeCtr) execCalls(t *testing.T) [][]string {
12+ var out [][]string
13+ for _, c := range f.calls(t) {
14+ if c[0] == "exec" {
15+ out = append(out, c)
16+ }
17+ }
18+ return out
19+}
20+
21+func TestRunStepThroughDockerExec(t *testing.T) {
22+ f := newFakeCtr(t)
23+ f.set(t, "exec_out", "hello from the container\n")
24+ var work string
25+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n",
26+ " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot })
27+ if res.Conclusion != ConclusionSuccess {
28+ t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers")))
29+ }
30+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" {
31+ t.Fatalf("steps %s", got)
32+ }
33+ ex := f.execCalls(t)
34+ if len(ex) != 1 {
35+ t.Fatalf("exec calls %v", ex)
36+ }
37+ c := strings.Join(ex[0], "\n")
38+ id := strings.Repeat("0", 63) + "1"
39+ for _, w := range []string{
40+ "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n",
41+ "\n" + id + "\nsh\n-e\n/__w/_temp/",
42+ } {
43+ if !strings.Contains(c, w) {
44+ t.Errorf("exec lacks %q:\n%s", w, c)
45+ }
46+ }
47+ if strings.Contains(c, "bar") || strings.Contains(c, work) {
48+ t.Errorf("values and host paths stay off the command line:\n%s", c)
49+ }
50+ log := logOf(rec.step(t, "s"))
51+ if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") {
52+ t.Errorf("log:\n%s", log)
53+ }
54+ // The create command of the job container.
55+ create := logOf(rec.step(t, "Initialize containers"))
56+ for _, w := range []string{
57+ "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"",
58+ "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"",
59+ } {
60+ if !strings.Contains(create, w) {
61+ t.Errorf("create lacks %q:\n%s", w, create)
62+ }
63+ }
64+}
65+
66+func TestRunEnvIsTranslatedForTheContainer(t *testing.T) {
67+ f := newFakeCtr(t)
68+ f.set(t, "exec_env", "1")
69+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n",
70+ " - name: s\n run: echo hi\n", nil)
71+ if res.Conclusion != ConclusionSuccess {
72+ t.Fatalf("%+v %v", res, rec.names())
73+ }
74+ // HOME is the container's own (/github/home): it is not passed with -e.
75+ if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") {
76+ t.Errorf("HOME must not be passed:\n%s", c)
77+ }
78+ log := logOf(rec.step(t, "s"))
79+ for _, w := range []string{
80+ "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp",
81+ "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json",
82+ } {
83+ if !strings.Contains(log, w) {
84+ t.Errorf("log lacks %q:\n%s", w, log)
85+ }
86+ }
87+}
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+// execCalls are the `docker exec` calls recorded by the fake.
11+func (f *fakeCtr) execCalls(t *testing.T) [][]string {
12+ var out [][]string
13+ for _, c := range f.calls(t) {
14+ if c[0] == "exec" {
15+ out = append(out, c)
16+ }
17+ }
18+ return out
19+}
20+
21+func TestRunStepThroughDockerExec(t *testing.T) {
22+ f := newFakeCtr(t)
23+ f.set(t, "exec_out", "hello from the container\n")
24+ var work string
25+ rec, res := ctrRun(t, f, " container:\n image: ubuntu:24.04\n env:\n CVAR: c\n",
26+ " - name: s\n working-directory: sub\n env:\n FOO: bar\n run: echo hi\n", func(c *Config) { work = c.Layout.WorkRoot })
27+ if res.Conclusion != ConclusionSuccess {
28+ t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers")))
29+ }
30+ if got := strings.Join(rec.names(), "|"); got != "Set up job|Initialize containers|s|Stop containers|Complete job" {
31+ t.Fatalf("steps %s", got)
32+ }
33+ ex := f.execCalls(t)
34+ if len(ex) != 1 {
35+ t.Fatalf("exec calls %v", ex)
36+ }
37+ c := strings.Join(ex[0], "\n")
38+ id := strings.Repeat("0", 63) + "1"
39+ for _, w := range []string{
40+ "exec\n-i\n--workdir\n/__w/hello/hello/sub\n-e\nFOO\n", "\n-e\nGITHUB_WORKSPACE\n", "\n-e\nGITHUB_OUTPUT\n",
41+ "\n" + id + "\nsh\n-e\n/__w/_temp/",
42+ } {
43+ if !strings.Contains(c, w) {
44+ t.Errorf("exec lacks %q:\n%s", w, c)
45+ }
46+ }
47+ if strings.Contains(c, "bar") || strings.Contains(c, work) {
48+ t.Errorf("values and host paths stay off the command line:\n%s", c)
49+ }
50+ log := logOf(rec.step(t, "s"))
51+ if !strings.Contains(log, "shell: sh -e {0}\n") || !strings.Contains(log, "hello from the container") {
52+ t.Errorf("log:\n%s", log)
53+ }
54+ // The create command of the job container.
55+ create := logOf(rec.step(t, "Initialize containers"))
56+ for _, w := range []string{
57+ "--workdir /__w/hello/hello --network github_network_", " -e \"CVAR=c\" -e \"HOME=/github/home\" -e GITHUB_ACTIONS=true -e CI=true -v \"/var/run/docker.sock\":\"/var/run/docker.sock\" -v \"" + work + "\":\"/__w\"",
58+ "\":\"/__e\":ro -v \"" + work + "/_temp\":\"/__w/_temp\"", "\":\"/__t\" -v \"" + work + "/_temp/_github_home\":\"/github/home\" -v \"" + work + "/_temp/_github_workflow\":\"/github/workflow\" --entrypoint \"tail\" ubuntu:24.04 \"-f\" \"/dev/null\"",
59+ } {
60+ if !strings.Contains(create, w) {
61+ t.Errorf("create lacks %q:\n%s", w, create)
62+ }
63+ }
64+}
65+
66+func TestRunEnvIsTranslatedForTheContainer(t *testing.T) {
67+ f := newFakeCtr(t)
68+ f.set(t, "exec_env", "1")
69+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n",
70+ " - name: s\n run: echo hi\n", nil)
71+ if res.Conclusion != ConclusionSuccess {
72+ t.Fatalf("%+v %v", res, rec.names())
73+ }
74+ // HOME is the container's own (/github/home): it is not passed with -e.
75+ if c := strings.Join(f.execCalls(t)[0], "\n"); strings.Contains(c, "\nHOME\n") {
76+ t.Errorf("HOME must not be passed:\n%s", c)
77+ }
78+ log := logOf(rec.step(t, "s"))
79+ for _, w := range []string{
80+ "ENV GITHUB_WORKSPACE=/__w/hello/hello", "RUNNER_TEMP=/__w/_temp",
81+ "GITHUB_OUTPUT=/__w/_temp/_runner_file_commands/set_output_", "GITHUB_EVENT_PATH=/github/workflow/event.json",
82+ } {
83+ if !strings.Contains(log, w) {
84+ t.Errorf("log lacks %q:\n%s", w, log)
85+ }
86+ }
87+}
M internal/executor/containers_fake_test.go +2 -1
@@ -47,7 +47,8 @@ inspect) case "$*" in
4747 port) [ -f $D/ports ] && cat $D/ports ;;
4848 logs) echo "service log of $3" ;;
4949 rm) echo "$3" ;;
50-exec) [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }
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; }
5152 [ -f $D/exec_out ] && cat $D/exec_out ;;
5253 esac
5354 exit 0
@@ -47,7 +47,8 @@ inspect) case "$*" in
47 port) [ -f $D/ports ] && cat $D/ports ;;47 port) [ -f $D/ports ] && cat $D/ports ;;
48 logs) echo "service log of $3" ;;48 logs) echo "service log of $3" ;;
49 rm) echo "$3" ;;49 rm) echo "$3" ;;
50-exec) [ -f $D/hang_exec ] && { trap 'exit 130' INT TERM; while :; do sleep 0.05; done; }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/exec_out ] && cat $D/exec_out ;;52 [ -f $D/exec_out ] && cat $D/exec_out ;;
52 esac53 esac
53 exit 054 exit 0
M internal/executor/exec_step.go +17 -1
@@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
1515 return j.runAction(s, stepCancel)
1616 }
1717 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
18+ if err := j.containerReady(); err != nil {
19+ return fail(err)
20+ }
1821 failTemplate := func(err error) stepResult {
1922 j.errorf(num, "%s", templateNotValid(err, true))
2023 return resFailed
@@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
3437 if err != nil {
3538 return fail(err) // the group stays open, like the original
3639 }
40+ if c := j.stepContainer(); c != nil {
41+ argv, err := j.containerScript(c, plan, in, s)
42+ if err != nil {
43+ return fail(err)
44+ }
45+ return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel)
46+ }
3747 dir, argv, err := j.scriptCommand(plan, in)
3848 if err != nil {
3949 return fail(err)
@@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) {
120130 j.mu.Lock()
121131 lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
122132 j.mu.Unlock()
123- plan, err := PlanShell(in.shell, lookup)
133+ var plan ShellPlan
134+ var err error
135+ if j.stepContainer() != nil {
136+ plan, err = PlanShellInContainer(in.shell)
137+ } else {
138+ plan, err = PlanShell(in.shell, lookup)
139+ }
124140 if err != nil {
125141 return plan, err
126142 }
@@ -15,6 +15,9 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
15 return j.runAction(s, stepCancel)15 return j.runAction(s, stepCancel)
16 }16 }
17 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }17 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
18+ if err := j.containerReady(); err != nil {
19+ return fail(err)
20+ }
18 failTemplate := func(err error) stepResult {21 failTemplate := func(err error) stepResult {
19 j.errorf(num, "%s", templateNotValid(err, true))22 j.errorf(num, "%s", templateNotValid(err, true))
20 return resFailed23 return resFailed
@@ -34,6 +37,13 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
34 if err != nil {37 if err != nil {
35 return fail(err) // the group stays open, like the original38 return fail(err) // the group stays open, like the original
36 }39 }
40+ if c := j.stepContainer(); c != nil {
41+ argv, err := j.containerScript(c, plan, in, s)
42+ if err != nil {
43+ return fail(err)
44+ }
45+ return j.runProc(s, argv[0], j.state.Layout().WorkRoot, argv, "", stepCancel)
46+ }
37 dir, argv, err := j.scriptCommand(plan, in)47 dir, argv, err := j.scriptCommand(plan, in)
38 if err != nil {48 if err != nil {
39 return fail(err)49 return fail(err)
@@ -120,7 +130,13 @@ func (j *job) printHeader(s *stepRun, in scriptIn) (ShellPlan, error) {
120 j.mu.Lock()130 j.mu.Lock()
121 lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}131 lookup := Lookup{Path: j.lookupPath, Prepend: j.state.Prepend()}
122 j.mu.Unlock()132 j.mu.Unlock()
123- plan, err := PlanShell(in.shell, lookup)133+ var plan ShellPlan
134+ var err error
135+ if j.stepContainer() != nil {
136+ plan, err = PlanShellInContainer(in.shell)
137+ } else {
138+ plan, err = PlanShell(in.shell, lookup)
139+ }
124 if err != nil {140 if err != nil {
125 return plan, err141 return plan, err
126 }142 }
M internal/executor/shell.go +29 -1
@@ -291,9 +291,21 @@ type Script struct {
291291 // so the file is created through root, exclusively and without following links
292292 // (see createExcl).
293293 func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {
294+ return WriteScriptFor(root, p, contents, nil)
295+}
296+
297+// WriteScriptFor is WriteScript for a shell that sees the file under another
298+// path: tr translates the host path for the arguments (a job container sees
299+// RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the
300+// host's.
301+func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) {
294302 name := uuid.NewString() + p.Extension()
295303 path := filepath.Join(root.Name(), name)
296- args, err := FormatArguments(p.ArgFormat, path)
304+ shown := path
305+ if tr != nil {
306+ shown = tr(path)
307+ }
308+ args, err := FormatArguments(p.ArgFormat, shown)
297309 if err != nil {
298310 return Script{}, err
299311 }
@@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string {
315327 }
316328 return strings.TrimRight(workspace, "/") + "/" + dir
317329 }
330+
331+// PlanShellInContainer is the shell of a step that runs in the job container
332+// (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is
333+// looked up, the shell is found by the container itself, so the default is
334+// always "sh -e {0}" even when the image has bash.
335+func PlanShellInContainer(shell string) (ShellPlan, error) {
336+ if shell == "" {
337+ return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")})
338+ }
339+ cmd, args := ParseShellOption(shell)
340+ format := strings.TrimLeft(args, " \t\n\v\f\r")
341+ if format == "" {
342+ format = ScriptArgumentsFormat(cmd)
343+ }
344+ return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format})
345+}
@@ -291,9 +291,21 @@ type Script struct {
291 // so the file is created through root, exclusively and without following links291 // so the file is created through root, exclusively and without following links
292 // (see createExcl).292 // (see createExcl).
293 func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {293 func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {
294+ return WriteScriptFor(root, p, contents, nil)
295+}
296+
297+// WriteScriptFor is WriteScript for a shell that sees the file under another
298+// path: tr translates the host path for the arguments (a job container sees
299+// RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the
300+// host's.
301+func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) {
294 name := uuid.NewString() + p.Extension()302 name := uuid.NewString() + p.Extension()
295 path := filepath.Join(root.Name(), name)303 path := filepath.Join(root.Name(), name)
296- args, err := FormatArguments(p.ArgFormat, path)304+ shown := path
305+ if tr != nil {
306+ shown = tr(path)
307+ }
308+ args, err := FormatArguments(p.ArgFormat, shown)
297 if err != nil {309 if err != nil {
298 return Script{}, err310 return Script{}, err
299 }311 }
@@ -315,3 +327,19 @@ func ResolveWorkingDirectory(workspace, dir string) string {
315 }327 }
316 return strings.TrimRight(workspace, "/") + "/" + dir328 return strings.TrimRight(workspace, "/") + "/" + dir
317 }329 }
330+
331+// PlanShellInContainer is the shell of a step that runs in the job container
332+// (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is
333+// looked up, the shell is found by the container itself, so the default is
334+// always "sh -e {0}" even when the image has bash.
335+func PlanShellInContainer(shell string) (ShellPlan, error) {
336+ if shell == "" {
337+ return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")})
338+ }
339+ cmd, args := ParseShellOption(shell)
340+ format := strings.TrimLeft(args, " \t\n\v\f\r")
341+ if format == "" {
342+ format = ScriptArgumentsFormat(cmd)
343+ }
344+ return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format})
345+}

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