detached 3a3049c

feat(executor): node actions in the job container, node_alpine for Alpine images

commit
3a3049c12d7511f244ee12073410258c049c8861
author
brice · · 2026-10-14 05:51 UTC
parent
85978ae
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +126 -0
M internal/executor/actions_node.go +3 -0
@@ -175,6 +175,9 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
175175 if st, err := os.Stat(script); err != nil || st.IsDir() {
176176 return fail(ghErrorf("File not found: '%s'", script))
177177 }
178+ if c := j.stepContainer(); c != nil {
179+ return j.runNodeInContainer(s, c, script, env, cancel)
180+ }
178181 j.mu.Lock()
179182 wd := j.state.GitHub("workspace")
180183 j.mu.Unlock()
@@ -175,6 +175,9 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
175 if st, err := os.Stat(script); err != nil || st.IsDir() {175 if st, err := os.Stat(script); err != nil || st.IsDir() {
176 return fail(ghErrorf("File not found: '%s'", script))176 return fail(ghErrorf("File not found: '%s'", script))
177 }177 }
178+ if c := j.stepContainer(); c != nil {
179+ return j.runNodeInContainer(s, c, script, env, cancel)
180+ }
178 j.mu.Lock()181 j.mu.Lock()
179 wd := j.state.GitHub("workspace")182 wd := j.state.GitHub("workspace")
180 j.mu.Unlock()183 j.mu.Unlock()
M internal/executor/containers_exec_test.go +31 -0
@@ -85,3 +85,34 @@ func TestRunEnvIsTranslatedForTheContainer(t *testing.T) {
8585 }
8686 }
8787 }
88+
89+func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) {
90+ for _, tc := range []struct{ release, node string }{{"ID=ubuntu\nVERSION_ID=\"24.04\"\n", "node24"}, {"ID=alpine\nVERSION_ID=3.23.4\n", "node24_alpine"}} {
91+ f := newFakeCtr(t)
92+ f.set(t, "exec_out", tc.release)
93+ tree := files{
94+ ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n",
95+ ".github/actions/x/index.js": "x\n",
96+ }
97+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n", func(c *Config) {
98+ writeFiles(t, c.Layout.Workspace, tree)
99+ })
100+ if res.Conclusion != ConclusionSuccess {
101+ t.Fatalf("%s: %+v\n%s", tc.node, res, logOf(rec.step(t, "a")))
102+ }
103+ ex := f.execCalls(t)
104+ if len(ex) != 2 {
105+ t.Fatalf("%s: exec calls %v", tc.node, ex)
106+ }
107+ if got := strings.Join(ex[0][len(ex[0])-3:], " "); got != "sh -c "+alpineCheck {
108+ t.Errorf("probe: %v", ex[0])
109+ }
110+ run := strings.Join(ex[1], "\n")
111+ if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") {
112+ t.Errorf("%s: node call:\n%s", tc.node, run)
113+ }
114+ if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") {
115+ t.Errorf("probe line missing:\n%s", got)
116+ }
117+ }
118+}
@@ -85,3 +85,34 @@ func TestRunEnvIsTranslatedForTheContainer(t *testing.T) {
85 }85 }
86 }86 }
87 }87 }
88+
89+func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) {
90+ for _, tc := range []struct{ release, node string }{{"ID=ubuntu\nVERSION_ID=\"24.04\"\n", "node24"}, {"ID=alpine\nVERSION_ID=3.23.4\n", "node24_alpine"}} {
91+ f := newFakeCtr(t)
92+ f.set(t, "exec_out", tc.release)
93+ tree := files{
94+ ".github/actions/x/action.yml": "name: x\nruns:\n using: node24\n main: index.js\n",
95+ ".github/actions/x/index.js": "x\n",
96+ }
97+ rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: a\n uses: ./.github/actions/x\n", func(c *Config) {
98+ writeFiles(t, c.Layout.Workspace, tree)
99+ })
100+ if res.Conclusion != ConclusionSuccess {
101+ t.Fatalf("%s: %+v\n%s", tc.node, res, logOf(rec.step(t, "a")))
102+ }
103+ ex := f.execCalls(t)
104+ if len(ex) != 2 {
105+ t.Fatalf("%s: exec calls %v", tc.node, ex)
106+ }
107+ if got := strings.Join(ex[0][len(ex[0])-3:], " "); got != "sh -c "+alpineCheck {
108+ t.Errorf("probe: %v", ex[0])
109+ }
110+ run := strings.Join(ex[1], "\n")
111+ if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") {
112+ t.Errorf("%s: node call:\n%s", tc.node, run)
113+ }
114+ if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") {
115+ t.Errorf("probe line missing:\n%s", got)
116+ }
117+ }
118+}
A internal/executor/containers_node.go +92 -0
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+)
11+
12+// alpineCheck is the command of ContainerStepHost.DetermineNodeRuntimeVersion
13+// (StepHost.cs:176): the distribution is read from the release files.
14+const alpineCheck = `cat /etc/*release | grep ^ID`
15+
16+// nodeInContainer is ContainerStepHost.DetermineNodeRuntimeVersion
17+// (StepHost.cs:154-196, container hooks off): "<version>_alpine" when the
18+// image is Alpine (musl, where the glibc builds do not run), else the version
19+// itself. The output of the probe is not logged, only the command is.
20+func (j *job) nodeInContainer(s *stepRun, c *container, version string, cancel <-chan struct{}) (string, error) {
21+ cmd := j.newDockerCmd("exec", dockerPart{line: ""}, raw(c.id),
22+ dockerPart{line: `sh -c "` + alpineCheck + `"`, args: []string{"sh", "-c", alpineCheck}})
23+ j.out(s.num, "##[command]"+cmd.line)
24+ var out []string
25+ res, err := runProcess(procSpec{
26+ Argv: cmd.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
27+ OnLine: func(l string) {
28+ if l != "" {
29+ out = append(out, l)
30+ }
31+ },
32+ OnErrLine: func(l string) {
33+ if l != "" {
34+ out = append(out, l)
35+ }
36+ },
37+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
38+ })
39+ if err != nil {
40+ return "", err
41+ }
42+ if res.Cancelled {
43+ return "", errCancelled
44+ }
45+ if res.ExitCode == 0 {
46+ for _, l := range out {
47+ j.debug(s.num, l)
48+ if strings.Contains(strings.ToLower(l), "alpine") {
49+ return j.alpineNode(s, version)
50+ }
51+ }
52+ }
53+ j.debug(s.num, "Running JavaScript Action with default external tool: "+version)
54+ return version, nil
55+}
56+
57+// alpineNode is CheckPlatformForAlpineContainer (StepHost.cs:248-279): the
58+// alpine builds exist for x64 only.
59+func (j *job) alpineNode(s *stepRun, version string) (string, error) {
60+ if runtime.GOARCH != "amd64" {
61+ arch := map[string]string{"arm64": "Arm64", "arm": "Arm", "386": "X86"}[runtime.GOARCH]
62+ if arch == "" {
63+ arch = runtime.GOARCH
64+ }
65+ return "", fmt.Errorf("JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected Linux %s", arch)
66+ }
67+ v := version + "_alpine"
68+ j.debug(s.num, "Container distribution is alpine. Running JavaScript Action with external tool: "+v)
69+ return v, nil
70+}
71+
72+// runNodeInContainer is NodeScriptActionHandler.RunAsync (:127-190) with a
73+// ContainerStepHost: the node of the externals mounted at /__e runs the script
74+// at its container path, in the container's workspace.
75+func (j *job) runNodeInContainer(s *stepRun, c *container, script string, env *ordered, cancel <-chan struct{}) stepResult {
76+ num := s.num
77+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
78+ if err := j.containerReady(); err != nil {
79+ return fail(err)
80+ }
81+ version, err := j.nodeInContainer(s, c, s.nodeVersion, cancel)
82+ if err != nil {
83+ return j.containerResult(s, err)
84+ }
85+ file := c.resolvePath(filepath.Join(j.externalsDir(), version, "bin", "node"))
86+ j.mu.Lock()
87+ wd := j.state.GitHub("workspace")
88+ j.mu.Unlock()
89+ argv, penv := j.execArgv(c, c.resolvePath(wd), j.handlerEnv(env), file, []string{c.resolvePath(`"` + script + `"`)})
90+ s.rawEnvList = penv
91+ return j.runProc(s, file, j.state.Layout().WorkRoot, argv, "Node Action run completed with exit code %d", cancel)
92+}
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+)
11+
12+// alpineCheck is the command of ContainerStepHost.DetermineNodeRuntimeVersion
13+// (StepHost.cs:176): the distribution is read from the release files.
14+const alpineCheck = `cat /etc/*release | grep ^ID`
15+
16+// nodeInContainer is ContainerStepHost.DetermineNodeRuntimeVersion
17+// (StepHost.cs:154-196, container hooks off): "<version>_alpine" when the
18+// image is Alpine (musl, where the glibc builds do not run), else the version
19+// itself. The output of the probe is not logged, only the command is.
20+func (j *job) nodeInContainer(s *stepRun, c *container, version string, cancel <-chan struct{}) (string, error) {
21+ cmd := j.newDockerCmd("exec", dockerPart{line: ""}, raw(c.id),
22+ dockerPart{line: `sh -c "` + alpineCheck + `"`, args: []string{"sh", "-c", alpineCheck}})
23+ j.out(s.num, "##[command]"+cmd.line)
24+ var out []string
25+ res, err := runProcess(procSpec{
26+ Argv: cmd.argv, Dir: dirOrCwd(j.githubValue("workspace")), Env: j.processEnv(newOrdered()), Credential: j.cfg.Credential,
27+ OnLine: func(l string) {
28+ if l != "" {
29+ out = append(out, l)
30+ }
31+ },
32+ OnErrLine: func(l string) {
33+ if l != "" {
34+ out = append(out, l)
35+ }
36+ },
37+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
38+ })
39+ if err != nil {
40+ return "", err
41+ }
42+ if res.Cancelled {
43+ return "", errCancelled
44+ }
45+ if res.ExitCode == 0 {
46+ for _, l := range out {
47+ j.debug(s.num, l)
48+ if strings.Contains(strings.ToLower(l), "alpine") {
49+ return j.alpineNode(s, version)
50+ }
51+ }
52+ }
53+ j.debug(s.num, "Running JavaScript Action with default external tool: "+version)
54+ return version, nil
55+}
56+
57+// alpineNode is CheckPlatformForAlpineContainer (StepHost.cs:248-279): the
58+// alpine builds exist for x64 only.
59+func (j *job) alpineNode(s *stepRun, version string) (string, error) {
60+ if runtime.GOARCH != "amd64" {
61+ arch := map[string]string{"arm64": "Arm64", "arm": "Arm", "386": "X86"}[runtime.GOARCH]
62+ if arch == "" {
63+ arch = runtime.GOARCH
64+ }
65+ return "", fmt.Errorf("JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected Linux %s", arch)
66+ }
67+ v := version + "_alpine"
68+ j.debug(s.num, "Container distribution is alpine. Running JavaScript Action with external tool: "+v)
69+ return v, nil
70+}
71+
72+// runNodeInContainer is NodeScriptActionHandler.RunAsync (:127-190) with a
73+// ContainerStepHost: the node of the externals mounted at /__e runs the script
74+// at its container path, in the container's workspace.
75+func (j *job) runNodeInContainer(s *stepRun, c *container, script string, env *ordered, cancel <-chan struct{}) stepResult {
76+ num := s.num
77+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
78+ if err := j.containerReady(); err != nil {
79+ return fail(err)
80+ }
81+ version, err := j.nodeInContainer(s, c, s.nodeVersion, cancel)
82+ if err != nil {
83+ return j.containerResult(s, err)
84+ }
85+ file := c.resolvePath(filepath.Join(j.externalsDir(), version, "bin", "node"))
86+ j.mu.Lock()
87+ wd := j.state.GitHub("workspace")
88+ j.mu.Unlock()
89+ argv, penv := j.execArgv(c, c.resolvePath(wd), j.handlerEnv(env), file, []string{c.resolvePath(`"` + script + `"`)})
90+ s.rawEnvList = penv
91+ return j.runProc(s, file, j.state.Layout().WorkRoot, argv, "Node Action run completed with exit code %d", cancel)
92+}

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