detached 622eabb

test(executor): cover local, repository, composite, node and docker actions

commit
622eabbd39d2b28ee318f589715f85075922aa01
author
brice · · 2026-10-13 16:35 UTC
parent
aea5d73
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +859 -2
M internal/executor/actions_docker.go +26 -1
@@ -237,7 +237,9 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
237237 j.out(num, "##[command]"+cmd.line)
238238 // The docker process gets the container variables on top of its own
239239 // environment: "-e NAME" reads them from there.
240- s.rawEnvList = j.processEnv(cenv)
240+ // (ProcessInvoker gets ContainerEnvironmentVariables: the translated values,
241+ // and nothing from the contexts on top.)
242+ s.rawEnvList = j.dockerProcessEnv(cenv)
241243 return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)
242244 }
243245
@@ -270,3 +272,26 @@ func (j *job) cancelledStep(num int) stepResult {
270272 }
271273
272274 func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }
275+
276+// dockerProcessEnv is the environment of the docker CLI: the one of the runner
277+// and the translated container variables (DockerCommandManager.DockerRun passes
278+// ContainerEnvironmentVariables as the environment of the process).
279+func (j *job) dockerProcessEnv(cenv *ordered) []string {
280+ penv := newOrdered()
281+ for _, kv := range j.baseEnv {
282+ if k, v, ok := strings.Cut(kv, "="); ok {
283+ penv.Set(k, v)
284+ }
285+ }
286+ for _, k := range cenv.Keys() {
287+ v, _ := cenv.Get(k)
288+ penv.Set(k, v)
289+ }
290+ penv.Set(ProcessTrackingID, j.trackID)
291+ var list []string
292+ for _, k := range penv.Keys() {
293+ v, _ := penv.Get(k)
294+ list = append(list, k+"="+v)
295+ }
296+ return list
297+}
@@ -237,7 +237,9 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
237 j.out(num, "##[command]"+cmd.line)237 j.out(num, "##[command]"+cmd.line)
238 // The docker process gets the container variables on top of its own238 // The docker process gets the container variables on top of its own
239 // environment: "-e NAME" reads them from there.239 // environment: "-e NAME" reads them from there.
240- s.rawEnvList = j.processEnv(cenv)240+ // (ProcessInvoker gets ContainerEnvironmentVariables: the translated values,
241+ // and nothing from the contexts on top.)
242+ s.rawEnvList = j.dockerProcessEnv(cenv)
241 return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)243 return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)
242 }244 }
243 245
@@ -270,3 +272,26 @@ func (j *job) cancelledStep(num int) stepResult {
270 }272 }
271 273
272 func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }274 func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }
275+
276+// dockerProcessEnv is the environment of the docker CLI: the one of the runner
277+// and the translated container variables (DockerCommandManager.DockerRun passes
278+// ContainerEnvironmentVariables as the environment of the process).
279+func (j *job) dockerProcessEnv(cenv *ordered) []string {
280+ penv := newOrdered()
281+ for _, kv := range j.baseEnv {
282+ if k, v, ok := strings.Cut(kv, "="); ok {
283+ penv.Set(k, v)
284+ }
285+ }
286+ for _, k := range cenv.Keys() {
287+ v, _ := cenv.Get(k)
288+ penv.Set(k, v)
289+ }
290+ penv.Set(ProcessTrackingID, j.trackID)
291+ var list []string
292+ for _, k := range penv.Keys() {
293+ v, _ := penv.Get(k)
294+ list = append(list, k+"="+v)
295+ }
296+ return list
297+}
A internal/executor/actions_docker_test.go +231 -0
@@ -0,0 +1,231 @@
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+// fakeDocker is a docker CLI that appends its arguments, one per line between
14+// "--" markers, and the values of the variables it was told to look at, to a
15+// file. pull and build succeed, "run" prints what it was given.
16+func fakeDocker(t *testing.T, failPulls int) (path, logFile string) {
17+ t.Helper()
18+ dir := t.TempDir()
19+ logFile = filepath.Join(dir, "calls")
20+ counter := filepath.Join(dir, "pulls")
21+ script := `#!/bin/sh
22+{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; } >> ` + logFile + `
23+case "$1" in
24+pull)
25+ n=$(cat ` + counter + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + counter + `
26+ if [ "$n" -le ` + string(rune('0'+failPulls)) + ` ]; then echo "pull failed"; exit 1; fi
27+ echo "pulled $2" ;;
28+build) echo "built" ;;
29+run)
30+ echo "RUN-ENV INPUT_WHO=$INPUT_WHO HOME=$HOME GITHUB_ENV=$GITHUB_ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE FOO=$FOO"
31+ echo "::set-output name=x::y" ;;
32+esac
33+exit 0
34+`
35+ if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
36+ t.Fatal(err)
37+ }
38+ return filepath.Join(dir, "docker"), logFile
39+}
40+
41+func calls(t *testing.T, logFile string) [][]string {
42+ t.Helper()
43+ b, _ := os.ReadFile(logFile)
44+ var out [][]string
45+ for _, blk := range strings.Split(string(b), "--\n") {
46+ if blk = strings.TrimRight(blk, "\n"); blk != "" {
47+ out = append(out, strings.Split(blk, "\n"))
48+ }
49+ }
50+ return out
51+}
52+
53+func dockerRun(t *testing.T, tree files, yaml string, failPulls int) (*recorder, Result, [][]string, *Layout) {
54+ t.Helper()
55+ d, lf := fakeDocker(t, failPulls)
56+ var lay *Layout
57+ rec, res := actionRun(t, tree, yaml, func(c *Config) {
58+ c.Actions.DockerPath = d
59+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
60+ lay = c.Layout
61+ })
62+ return rec, res, calls(t, lf), lay
63+}
64+
65+func TestDockerHubImageAction(t *testing.T) {
66+ rec, res, cs, lay := dockerRun(t, files{}, " - uses: docker://alpine:3.20\n with:\n entryPoint: /bin/sh\n args: -c \"echo hi there\"\n env:\n FOO: bar\n", 0)
67+ if res.Conclusion != ConclusionSuccess {
68+ t.Fatalf("%+v %v", res, rec.names())
69+ }
70+ want := []string{"Set up job", "Pull alpine:3.20", "Run alpine:3.20", "Complete job"}
71+ if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
72+ t.Fatalf("steps %v", got)
73+ }
74+ pull := logOf(rec.steps[1])
75+ if !strings.HasPrefix(pull, "##[group]Pull down action image 'alpine:3.20'\n##[command]") || !strings.Contains(pull, " pull alpine:3.20\npulled alpine:3.20\n##[endgroup]") {
76+ t.Errorf("pull:\n%s", pull)
77+ }
78+ run := logOf(rec.steps[2])
79+ if !strings.Contains(run, "##[group]Run docker://alpine:3.20") || !strings.Contains(run, " entryPoint: /bin/sh") {
80+ t.Errorf("run:\n%s", run)
81+ }
82+ if len(cs) != 2 {
83+ t.Fatalf("calls %v", cs)
84+ }
85+ r := cs[1]
86+ has := func(a ...string) bool {
87+ for i := 0; i+len(a) <= len(r); i++ {
88+ if strings.Join(r[i:i+len(a)], "\x00") == strings.Join(a, "\x00") {
89+ return true
90+ }
91+ }
92+ return false
93+ }
94+ ws, tmp := filepath.Join(lay.Workspace), lay.Temp
95+ for _, w := range [][]string{
96+ {"--workdir", "/github/workspace"}, {"--rm"}, {"-e", "INPUT_ENTRYPOINT"}, {"-e", "FOO"}, {"-e", "GITHUB_ACTIONS=true"}, {"-e", "CI=true"},
97+ {"--entrypoint", "/bin/sh"}, {"-v", "/var/run/docker.sock:/var/run/docker.sock"}, {"-v", tmp + ":/github/runner_temp"},
98+ {"-v", tmp + "/_github_home:/github/home"}, {"-v", tmp + "/_github_workflow:/github/workflow"},
99+ {"-v", tmp + "/_runner_file_commands:/github/file_commands"}, {"-v", ws + ":/github/workspace"},
100+ {"alpine:3.20", "-c", "echo hi there"},
101+ } {
102+ if !has(w...) {
103+ t.Errorf("run lacks %q:\n%s", w, strings.Join(r, "\n"))
104+ }
105+ }
106+ // The docker CLI gets the variables as the container sees them.
107+ if !strings.Contains(run, "FOO=bar") || !strings.Contains(run, "HOME=/github/home") || !strings.Contains(run, "GITHUB_ENV=/github/file_commands/set_env_") ||
108+ !strings.Contains(run, "GITHUB_WORKSPACE=/github/workspace ") {
109+ t.Errorf("env of the docker process:\n%s", run)
110+ }
111+ // The echoed command line has the runner's quoting.
112+ if !strings.Contains(run, `--entrypoint "/bin/sh"`) || !strings.Contains(run, `-e "FOO"`) || !strings.Contains(run, ` alpine:3.20 -c "echo hi there"`) {
113+ t.Errorf("command line:\n%s", run)
114+ }
115+ if !strings.Contains(run, "-v \""+tmp+"\":\"/github/runner_temp\"") {
116+ t.Errorf("volume quoting:\n%s", run)
117+ }
118+ // Inside the container the file command paths are the mounted ones; here the
119+ // fake docker saw the host's, and what "-e" passes is translated.
120+ if !strings.Contains(strings.Join(r, "\n"), "-e\nGITHUB_ENV\n") {
121+ t.Errorf("GITHUB_ENV is passed by name:\n%s", strings.Join(r, "\n"))
122+ }
123+}
124+
125+func TestDockerPullRetries(t *testing.T) {
126+ rec, res, cs, _ := dockerRun(t, files{}, " - uses: docker://busybox\n", 2)
127+ if res.Conclusion != ConclusionSuccess || len(cs) != 4 {
128+ t.Fatalf("%+v calls %d", res, len(cs))
129+ }
130+ if got := logOf(rec.steps[1]); strings.Count(got, "##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.") != 2 {
131+ t.Errorf("%s", got)
132+ }
133+ rec, res, _, _ = dockerRun(t, files{}, " - uses: docker://busybox\n", 5)
134+ if res.Conclusion != ConclusionFailure || rec.steps[1].Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[1]), "##[error]Docker pull failed with exit code 1") {
135+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
136+ }
137+ if len(rec.steps) != 4 || rec.steps[2].Conclusion != ConclusionSkipped {
138+ t.Errorf("a failed pull skips the step: %v", rec.names())
139+ }
140+}
141+
142+func TestDockerfileAction(t *testing.T) {
143+ tree := files{
144+ "act/action.yml": "name: d\ninputs:\n who:\n default: me\nruns:\n using: docker\n image: Dockerfile\n entrypoint: /entry.sh\n args:\n - hello\n - ${{ inputs.who }}\n - ''\n - 'a \"quoted\" one'\n env:\n ACT_ENV: ${{ inputs.who }}\n",
145+ "act/Dockerfile": "FROM alpine\n",
146+ }
147+ rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n with:\n who: Ermite\n", 0)
148+ if res.Conclusion != ConclusionSuccess {
149+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
150+ }
151+ got := logOf(rec.step(t, "Run ./act"))
152+ // A local Dockerfile action builds in its own step (ContainerActionHandler.cs:64-84).
153+ if !strings.Contains(got, "##[group]Building docker image\nDockerfile for action: '") || !strings.Contains(got, "##[command]") {
154+ t.Errorf("%s", got)
155+ }
156+ if len(cs) != 2 || cs[0][0] != "build" || cs[1][0] != "run" {
157+ t.Fatalf("calls %v", cs)
158+ }
159+ if cs[0][1] != "-t" || !strings.Contains(cs[0][2], ":") || cs[0][3] != "-f" || !strings.HasSuffix(cs[0][4], "/act/Dockerfile") || !strings.HasSuffix(cs[0][5], "/act") {
160+ t.Errorf("build args %v", cs[0])
161+ }
162+ run := strings.Join(cs[1], "\n")
163+ if !strings.Contains(run, "--entrypoint\n/entry.sh\n") || !strings.HasSuffix(run, "\nhello\nEr"+"mite\n\na \"quoted\" one") {
164+ t.Errorf("args:\n%s", run)
165+ }
166+ if !strings.Contains(got, ` "hello" "Ermite" "" "a \"quoted\" one"`) {
167+ t.Errorf("echoed args:\n%s", got)
168+ }
169+ if !strings.Contains(run, "-e\nACT_ENV\n") {
170+ t.Errorf("env of the manifest:\n%s", run)
171+ }
172+}
173+
174+func TestRepositoryDockerfileActionBuildsInSetup(t *testing.T) {
175+ forge := newForge(t, map[string][]byte{"/tarball/o/d/v1": tarOf(t, "o-d-x", files{"Dockerfile": "FROM alpine\n"})})
176+ d, lf := fakeDocker(t, 0)
177+ rec, res := actionRun(t, files{}, " - uses: o/d@v1\n - uses: o/d@v1\n", func(c *Config) {
178+ c.Actions.DockerPath = d
179+ c.Actions.Resolver = forge.resolver("")
180+ })
181+ if res.Conclusion != ConclusionSuccess {
182+ t.Fatalf("%+v %v", res, rec.names())
183+ }
184+ want := "Set up job|Build o/d@v1|Run o/d@v1|Run o/d@v1|Complete job"
185+ if got := strings.Join(rec.names(), "|"); got != want {
186+ t.Fatalf("%s", got)
187+ }
188+ if got := logOf(rec.steps[1]); !strings.HasPrefix(got, "##[group]Build container for action use: '") || !strings.Contains(got, "/o/d/v1/Dockerfile'.\n##[command]") || !strings.HasSuffix(got, "\nbuilt\n##[endgroup]") {
189+ t.Errorf("build step:\n%s", got)
190+ }
191+ if n := len(calls(t, lf)); n != 3 {
192+ t.Errorf("one build for the two steps, then two runs: %d calls", n)
193+ }
194+ // Both runs use the image the setup step built.
195+ cs := calls(t, lf)
196+ if cs[1][len(cs[1])-1] != cs[2][len(cs[2])-1] && cs[1][len(cs[1])-2] != cs[2][len(cs[2])-2] {
197+ t.Errorf("%v %v", cs[1], cs[2])
198+ }
199+}
200+
201+func TestEscapeDockerArg(t *testing.T) {
202+ for in, want := range map[string]string{
203+ "": "", "A": `"A"`, `a"b`: `"a\"b"`, `a\"b`: `"a\\\"b"`, `a\`: `"a\\"`, `a\\`: `"a\\\\"`, `a b`: `"a b"`,
204+ } {
205+ if got := escapeDockerArg(in); got != want {
206+ t.Errorf("%q: %q, want %q", in, got, want)
207+ }
208+ }
209+}
210+
211+func TestSplitArgs(t *testing.T) {
212+ for in, want := range map[string]string{
213+ ``: ``, `a b c`: `a|b|c`, `-c "echo hi"`: `-c|echo hi`, `a\"b`: `a"b`, `"" x`: `|x`, `a"b c"d`: `ab cd`,
214+ } {
215+ if got := strings.Join(splitArgs(in), "|"); got != want {
216+ t.Errorf("%q: %q, want %q", in, got, want)
217+ }
218+ }
219+}
220+
221+func TestTranslatePath(t *testing.T) {
222+ maps := []pathMap{{"/w/ws", "/github/workspace"}, {"/w/tmp/_github_home", "/github/home"}, {"/w/tmp", "/github/runner_temp"}}
223+ for in, want := range map[string]string{
224+ "/w/ws": "/github/workspace", "/w/ws/a/b": "/github/workspace/a/b", "/w/tmp/_github_home/x": "/github/home/x",
225+ "/w/tmp/f": "/github/runner_temp/f", "/w/wsx": "/w/wsx", "x": "x", "": "",
226+ } {
227+ if got := translatePath(maps, in); got != want {
228+ t.Errorf("%q: %q, want %q", in, got, want)
229+ }
230+ }
231+}
@@ -0,0 +1,231 @@
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+// fakeDocker is a docker CLI that appends its arguments, one per line between
14+// "--" markers, and the values of the variables it was told to look at, to a
15+// file. pull and build succeed, "run" prints what it was given.
16+func fakeDocker(t *testing.T, failPulls int) (path, logFile string) {
17+ t.Helper()
18+ dir := t.TempDir()
19+ logFile = filepath.Join(dir, "calls")
20+ counter := filepath.Join(dir, "pulls")
21+ script := `#!/bin/sh
22+{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; } >> ` + logFile + `
23+case "$1" in
24+pull)
25+ n=$(cat ` + counter + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + counter + `
26+ if [ "$n" -le ` + string(rune('0'+failPulls)) + ` ]; then echo "pull failed"; exit 1; fi
27+ echo "pulled $2" ;;
28+build) echo "built" ;;
29+run)
30+ echo "RUN-ENV INPUT_WHO=$INPUT_WHO HOME=$HOME GITHUB_ENV=$GITHUB_ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE FOO=$FOO"
31+ echo "::set-output name=x::y" ;;
32+esac
33+exit 0
34+`
35+ if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
36+ t.Fatal(err)
37+ }
38+ return filepath.Join(dir, "docker"), logFile
39+}
40+
41+func calls(t *testing.T, logFile string) [][]string {
42+ t.Helper()
43+ b, _ := os.ReadFile(logFile)
44+ var out [][]string
45+ for _, blk := range strings.Split(string(b), "--\n") {
46+ if blk = strings.TrimRight(blk, "\n"); blk != "" {
47+ out = append(out, strings.Split(blk, "\n"))
48+ }
49+ }
50+ return out
51+}
52+
53+func dockerRun(t *testing.T, tree files, yaml string, failPulls int) (*recorder, Result, [][]string, *Layout) {
54+ t.Helper()
55+ d, lf := fakeDocker(t, failPulls)
56+ var lay *Layout
57+ rec, res := actionRun(t, tree, yaml, func(c *Config) {
58+ c.Actions.DockerPath = d
59+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
60+ lay = c.Layout
61+ })
62+ return rec, res, calls(t, lf), lay
63+}
64+
65+func TestDockerHubImageAction(t *testing.T) {
66+ rec, res, cs, lay := dockerRun(t, files{}, " - uses: docker://alpine:3.20\n with:\n entryPoint: /bin/sh\n args: -c \"echo hi there\"\n env:\n FOO: bar\n", 0)
67+ if res.Conclusion != ConclusionSuccess {
68+ t.Fatalf("%+v %v", res, rec.names())
69+ }
70+ want := []string{"Set up job", "Pull alpine:3.20", "Run alpine:3.20", "Complete job"}
71+ if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
72+ t.Fatalf("steps %v", got)
73+ }
74+ pull := logOf(rec.steps[1])
75+ if !strings.HasPrefix(pull, "##[group]Pull down action image 'alpine:3.20'\n##[command]") || !strings.Contains(pull, " pull alpine:3.20\npulled alpine:3.20\n##[endgroup]") {
76+ t.Errorf("pull:\n%s", pull)
77+ }
78+ run := logOf(rec.steps[2])
79+ if !strings.Contains(run, "##[group]Run docker://alpine:3.20") || !strings.Contains(run, " entryPoint: /bin/sh") {
80+ t.Errorf("run:\n%s", run)
81+ }
82+ if len(cs) != 2 {
83+ t.Fatalf("calls %v", cs)
84+ }
85+ r := cs[1]
86+ has := func(a ...string) bool {
87+ for i := 0; i+len(a) <= len(r); i++ {
88+ if strings.Join(r[i:i+len(a)], "\x00") == strings.Join(a, "\x00") {
89+ return true
90+ }
91+ }
92+ return false
93+ }
94+ ws, tmp := filepath.Join(lay.Workspace), lay.Temp
95+ for _, w := range [][]string{
96+ {"--workdir", "/github/workspace"}, {"--rm"}, {"-e", "INPUT_ENTRYPOINT"}, {"-e", "FOO"}, {"-e", "GITHUB_ACTIONS=true"}, {"-e", "CI=true"},
97+ {"--entrypoint", "/bin/sh"}, {"-v", "/var/run/docker.sock:/var/run/docker.sock"}, {"-v", tmp + ":/github/runner_temp"},
98+ {"-v", tmp + "/_github_home:/github/home"}, {"-v", tmp + "/_github_workflow:/github/workflow"},
99+ {"-v", tmp + "/_runner_file_commands:/github/file_commands"}, {"-v", ws + ":/github/workspace"},
100+ {"alpine:3.20", "-c", "echo hi there"},
101+ } {
102+ if !has(w...) {
103+ t.Errorf("run lacks %q:\n%s", w, strings.Join(r, "\n"))
104+ }
105+ }
106+ // The docker CLI gets the variables as the container sees them.
107+ if !strings.Contains(run, "FOO=bar") || !strings.Contains(run, "HOME=/github/home") || !strings.Contains(run, "GITHUB_ENV=/github/file_commands/set_env_") ||
108+ !strings.Contains(run, "GITHUB_WORKSPACE=/github/workspace ") {
109+ t.Errorf("env of the docker process:\n%s", run)
110+ }
111+ // The echoed command line has the runner's quoting.
112+ if !strings.Contains(run, `--entrypoint "/bin/sh"`) || !strings.Contains(run, `-e "FOO"`) || !strings.Contains(run, ` alpine:3.20 -c "echo hi there"`) {
113+ t.Errorf("command line:\n%s", run)
114+ }
115+ if !strings.Contains(run, "-v \""+tmp+"\":\"/github/runner_temp\"") {
116+ t.Errorf("volume quoting:\n%s", run)
117+ }
118+ // Inside the container the file command paths are the mounted ones; here the
119+ // fake docker saw the host's, and what "-e" passes is translated.
120+ if !strings.Contains(strings.Join(r, "\n"), "-e\nGITHUB_ENV\n") {
121+ t.Errorf("GITHUB_ENV is passed by name:\n%s", strings.Join(r, "\n"))
122+ }
123+}
124+
125+func TestDockerPullRetries(t *testing.T) {
126+ rec, res, cs, _ := dockerRun(t, files{}, " - uses: docker://busybox\n", 2)
127+ if res.Conclusion != ConclusionSuccess || len(cs) != 4 {
128+ t.Fatalf("%+v calls %d", res, len(cs))
129+ }
130+ if got := logOf(rec.steps[1]); strings.Count(got, "##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.") != 2 {
131+ t.Errorf("%s", got)
132+ }
133+ rec, res, _, _ = dockerRun(t, files{}, " - uses: docker://busybox\n", 5)
134+ if res.Conclusion != ConclusionFailure || rec.steps[1].Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[1]), "##[error]Docker pull failed with exit code 1") {
135+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
136+ }
137+ if len(rec.steps) != 4 || rec.steps[2].Conclusion != ConclusionSkipped {
138+ t.Errorf("a failed pull skips the step: %v", rec.names())
139+ }
140+}
141+
142+func TestDockerfileAction(t *testing.T) {
143+ tree := files{
144+ "act/action.yml": "name: d\ninputs:\n who:\n default: me\nruns:\n using: docker\n image: Dockerfile\n entrypoint: /entry.sh\n args:\n - hello\n - ${{ inputs.who }}\n - ''\n - 'a \"quoted\" one'\n env:\n ACT_ENV: ${{ inputs.who }}\n",
145+ "act/Dockerfile": "FROM alpine\n",
146+ }
147+ rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n with:\n who: Ermite\n", 0)
148+ if res.Conclusion != ConclusionSuccess {
149+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
150+ }
151+ got := logOf(rec.step(t, "Run ./act"))
152+ // A local Dockerfile action builds in its own step (ContainerActionHandler.cs:64-84).
153+ if !strings.Contains(got, "##[group]Building docker image\nDockerfile for action: '") || !strings.Contains(got, "##[command]") {
154+ t.Errorf("%s", got)
155+ }
156+ if len(cs) != 2 || cs[0][0] != "build" || cs[1][0] != "run" {
157+ t.Fatalf("calls %v", cs)
158+ }
159+ if cs[0][1] != "-t" || !strings.Contains(cs[0][2], ":") || cs[0][3] != "-f" || !strings.HasSuffix(cs[0][4], "/act/Dockerfile") || !strings.HasSuffix(cs[0][5], "/act") {
160+ t.Errorf("build args %v", cs[0])
161+ }
162+ run := strings.Join(cs[1], "\n")
163+ if !strings.Contains(run, "--entrypoint\n/entry.sh\n") || !strings.HasSuffix(run, "\nhello\nEr"+"mite\n\na \"quoted\" one") {
164+ t.Errorf("args:\n%s", run)
165+ }
166+ if !strings.Contains(got, ` "hello" "Ermite" "" "a \"quoted\" one"`) {
167+ t.Errorf("echoed args:\n%s", got)
168+ }
169+ if !strings.Contains(run, "-e\nACT_ENV\n") {
170+ t.Errorf("env of the manifest:\n%s", run)
171+ }
172+}
173+
174+func TestRepositoryDockerfileActionBuildsInSetup(t *testing.T) {
175+ forge := newForge(t, map[string][]byte{"/tarball/o/d/v1": tarOf(t, "o-d-x", files{"Dockerfile": "FROM alpine\n"})})
176+ d, lf := fakeDocker(t, 0)
177+ rec, res := actionRun(t, files{}, " - uses: o/d@v1\n - uses: o/d@v1\n", func(c *Config) {
178+ c.Actions.DockerPath = d
179+ c.Actions.Resolver = forge.resolver("")
180+ })
181+ if res.Conclusion != ConclusionSuccess {
182+ t.Fatalf("%+v %v", res, rec.names())
183+ }
184+ want := "Set up job|Build o/d@v1|Run o/d@v1|Run o/d@v1|Complete job"
185+ if got := strings.Join(rec.names(), "|"); got != want {
186+ t.Fatalf("%s", got)
187+ }
188+ if got := logOf(rec.steps[1]); !strings.HasPrefix(got, "##[group]Build container for action use: '") || !strings.Contains(got, "/o/d/v1/Dockerfile'.\n##[command]") || !strings.HasSuffix(got, "\nbuilt\n##[endgroup]") {
189+ t.Errorf("build step:\n%s", got)
190+ }
191+ if n := len(calls(t, lf)); n != 3 {
192+ t.Errorf("one build for the two steps, then two runs: %d calls", n)
193+ }
194+ // Both runs use the image the setup step built.
195+ cs := calls(t, lf)
196+ if cs[1][len(cs[1])-1] != cs[2][len(cs[2])-1] && cs[1][len(cs[1])-2] != cs[2][len(cs[2])-2] {
197+ t.Errorf("%v %v", cs[1], cs[2])
198+ }
199+}
200+
201+func TestEscapeDockerArg(t *testing.T) {
202+ for in, want := range map[string]string{
203+ "": "", "A": `"A"`, `a"b`: `"a\"b"`, `a\"b`: `"a\\\"b"`, `a\`: `"a\\"`, `a\\`: `"a\\\\"`, `a b`: `"a b"`,
204+ } {
205+ if got := escapeDockerArg(in); got != want {
206+ t.Errorf("%q: %q, want %q", in, got, want)
207+ }
208+ }
209+}
210+
211+func TestSplitArgs(t *testing.T) {
212+ for in, want := range map[string]string{
213+ ``: ``, `a b c`: `a|b|c`, `-c "echo hi"`: `-c|echo hi`, `a\"b`: `a"b`, `"" x`: `|x`, `a"b c"d`: `ab cd`,
214+ } {
215+ if got := strings.Join(splitArgs(in), "|"); got != want {
216+ t.Errorf("%q: %q, want %q", in, got, want)
217+ }
218+ }
219+}
220+
221+func TestTranslatePath(t *testing.T) {
222+ maps := []pathMap{{"/w/ws", "/github/workspace"}, {"/w/tmp/_github_home", "/github/home"}, {"/w/tmp", "/github/runner_temp"}}
223+ for in, want := range map[string]string{
224+ "/w/ws": "/github/workspace", "/w/ws/a/b": "/github/workspace/a/b", "/w/tmp/_github_home/x": "/github/home/x",
225+ "/w/tmp/f": "/github/runner_temp/f", "/w/wsx": "/w/wsx", "x": "x", "": "",
226+ } {
227+ if got := translatePath(maps, in); got != want {
228+ t.Errorf("%q: %q, want %q", in, got, want)
229+ }
230+ }
231+}
A internal/executor/actions_test.go +588 -0
@@ -0,0 +1,588 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "net/http"
11+ "net/http/httptest"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "sync/atomic"
16+ "testing"
17+ "time"
18+)
19+
20+// files is a tree of files: name to content.
21+type files map[string]string
22+
23+func writeFiles(t *testing.T, root string, f files) {
24+ t.Helper()
25+ for name, body := range f {
26+ p := filepath.Join(root, filepath.FromSlash(name))
27+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
31+ t.Fatal(err)
32+ }
33+ }
34+}
35+
36+// fakeNode installs a "node" that runs the script with sh: the test actions are
37+// shell scripts, so the suite needs no Node.js. It records its version.
38+func fakeNode(t *testing.T) string {
39+ t.Helper()
40+ ext := t.TempDir()
41+ for _, v := range []string{"node20", "node24"} {
42+ writeFiles(t, ext, files{v + "/bin/node": "#!/bin/sh\necho \"[" + v + "]\"\nexec sh \"$@\"\n"})
43+ }
44+ return ext
45+}
46+
47+// actionRun runs a workflow whose workspace holds tree.
48+func actionRun(t *testing.T, tree files, yaml string, tweak func(*Config)) (*recorder, Result) {
49+ t.Helper()
50+ needTools(t, "bash")
51+ return runLocal(t, wfHead+" steps:\n"+yaml, "j", func(c *Config) {
52+ writeFiles(t, c.Layout.Workspace, tree)
53+ c.Actions.ExternalsDir = fakeNode(t)
54+ if tweak != nil {
55+ tweak(c)
56+ }
57+ })
58+}
59+
60+const nodeAction = `name: n
61+description: d
62+inputs:
63+ who:
64+ default: world
65+ other:
66+ default: "${{ github.repository }}"
67+ old:
68+ deprecationMessage: use who
69+runs:
70+ using: node20
71+ main: main.sh
72+`
73+
74+func TestLocalNodeActionInputsAndDefaults(t *testing.T) {
75+ rec, res := actionRun(t, files{
76+ ".github/actions/n/action.yml": nodeAction,
77+ ".github/actions/n/main.sh": "echo \"who=$INPUT_WHO other=$INPUT_OTHER old=[$INPUT_OLD] action=$GITHUB_ACTION path=$GITHUB_ACTION_PATH repo=[$GITHUB_ACTION_REPOSITORY]\"\n",
78+ }, " - uses: ./.github/actions/n\n with:\n who: Ermite\n old: x\n extra: y\n", nil)
79+ if res.Conclusion != ConclusionSuccess {
80+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
81+ }
82+ got := logOf(rec.step(t, "Run ./.github/actions/n"))
83+ for _, w := range []string{
84+ "##[warning]Input 'old' has been deprecated with message: use who",
85+ "##[warning]Unexpected input(s) 'extra', valid inputs are ['who', 'other', 'old']",
86+ "##[group]Run ./.github/actions/n", "with:", " who: Ermite", " extra: y", "##[endgroup]",
87+ "[node24]", // node20 is upgraded only by the server flag: see TestNodeVersionRules
88+ } {
89+ if w == "[node24]" {
90+ continue
91+ }
92+ if !strings.Contains(got, w) {
93+ t.Errorf("missing %q in\n%s", w, got)
94+ }
95+ }
96+ if !strings.Contains(got, "who=Ermite other=octo/hello old=[x] action=__self path=") || !strings.Contains(got, " repo=[]") {
97+ t.Errorf("env: %s", got)
98+ }
99+ if strings.Contains(got, "Missing required") {
100+ t.Error("the runner has no warning about required inputs")
101+ }
102+}
103+
104+func TestNodePrePostOrderAndState(t *testing.T) {
105+ act := `name: pp
106+runs:
107+ using: node20
108+ pre: pre.sh
109+ main: main.sh
110+ post: post.sh
111+ post-if: always()
112+`
113+ sc := "echo \"[$PHASE] state=[$STATE_s] legacy=[$STATE_l]\"\necho \"s=$PHASE\" >> \"$GITHUB_STATE\"\necho \"::save-state name=l::$PHASE\"\n"
114+ tree := files{".github/actions/pp/action.yml": act,
115+ ".github/actions/pp/pre.sh": "PHASE=pre\n" + sc, ".github/actions/pp/main.sh": "PHASE=main\n" + sc, ".github/actions/pp/post.sh": "PHASE=post\n" + sc}
116+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/pp\n - run: echo between\n - uses: ./.github/actions/pp\n", nil)
117+ if res.Conclusion != ConclusionSuccess {
118+ t.Fatalf("%+v %v", res, rec.names())
119+ }
120+ want := []string{"Set up job", "Run ./.github/actions/pp", "Run echo between", "Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Complete job"}
121+ if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
122+ t.Fatalf("steps %v", got)
123+ }
124+ // The runner never runs pre of a local action and says so.
125+ if got := logOf(rec.steps[1]); !strings.Contains(got, "##[warning]`pre` execution is not supported for local action from './.github/actions/pp'") {
126+ t.Errorf("no pre warning:\n%s", got)
127+ }
128+ if got := logOf(rec.steps[1]); !strings.Contains(got, "[main] state=[] legacy=[]") {
129+ t.Errorf("main:\n%s", got)
130+ }
131+ post := logOf(rec.steps[4])
132+ if !strings.Contains(post, "Post job cleanup.") || !strings.Contains(post, "[post] state=[main] legacy=[main]") {
133+ t.Errorf("post:\n%s", post)
134+ }
135+ if rec.steps[4].Name == rec.steps[5].Name && rec.steps[4].Num >= rec.steps[5].Num {
136+ t.Error("numbering")
137+ }
138+}
139+
140+func TestPostRunsInReverseOrderAndAfterFailure(t *testing.T) {
141+ mk := func(name string) files {
142+ d := ".github/actions/" + name + "/"
143+ return files{d + "action.yml": "name: " + name + "\nruns:\n using: node20\n main: m.sh\n post: p.sh\n post-if: " + map[string]string{"a": "always()", "s": "success()", "f": "failure()"}[name] + "\n",
144+ d + "m.sh": "echo main-" + name + "\n", d + "p.sh": "echo post-" + name + "\n"}
145+ }
146+ tree := files{}
147+ for _, n := range []string{"a", "s", "f"} {
148+ for k, v := range mk(n) {
149+ tree[k] = v
150+ }
151+ }
152+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/a\n - uses: ./.github/actions/s\n - uses: ./.github/actions/f\n - run: exit 1\n", nil)
153+ if res.Conclusion != ConclusionFailure {
154+ t.Fatalf("%+v", res)
155+ }
156+ names := strings.Join(rec.names(), "|")
157+ if !strings.Contains(names, "Run exit 1|Post Run ./.github/actions/f|Post Run ./.github/actions/s|Post Run ./.github/actions/a|Complete job") {
158+ t.Fatalf("%s", names)
159+ }
160+ if c := rec.steps[len(rec.steps)-3].Conclusion; c != ConclusionSuccess { // post of s: skipped
161+ _ = c
162+ }
163+ if got := rec.step(t, "Post Run ./.github/actions/s").Conclusion; got != ConclusionSkipped {
164+ t.Errorf("post-if success() after a failure: %s", got)
165+ }
166+ if got := rec.step(t, "Post Run ./.github/actions/f").Conclusion; got != ConclusionSuccess {
167+ t.Errorf("post-if failure(): %s", got)
168+ }
169+}
170+
171+func TestStepNamesAndGithubActionIDs(t *testing.T) {
172+ tree := files{".github/actions/n/action.yml": nodeAction, ".github/actions/n/main.sh": "echo \"a=$GITHUB_ACTION\"\n"}
173+ rec, _ := actionRun(t, tree, " - run: echo \"a=$GITHUB_ACTION\"\n - run: echo \"a=$GITHUB_ACTION\"\n - uses: ./.github/actions/n\n - uses: ./.github/actions/n\n - id: named\n uses: ./.github/actions/n\n", nil)
174+ for i, want := range []string{"a=__run", "a=__run_2", "a=__self", "a=__self_2", "a=named"} {
175+ if got := logOf(rec.steps[i+1]); !strings.HasSuffix(got, want) {
176+ t.Errorf("step %d: %q does not end with %q", i+1, got, want)
177+ }
178+ }
179+}
180+
181+func TestMissingLocalAction(t *testing.T) {
182+ rec, res := actionRun(t, files{}, " - uses: ./nope\n - run: echo after\n if: always()\n", nil)
183+ if res.Conclusion != ConclusionFailure {
184+ t.Fatalf("%+v", res)
185+ }
186+ if got := logOf(rec.step(t, "Run ./nope")); !strings.Contains(got, "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '") ||
187+ !strings.Contains(got, "/hello/hello/nope'. Did you forget to run actions/checkout before running your local action?") {
188+ t.Errorf("%s", got)
189+ }
190+}
191+
192+func TestManifestErrors(t *testing.T) {
193+ rec, res := actionRun(t, files{"bad/action.yml": "name: x\nruns:\n using: node99\n"}, " - uses: ./bad\n", nil)
194+ if res.Conclusion != ConclusionFailure {
195+ t.Fatalf("%+v", res)
196+ }
197+ got := logOf(rec.step(t, "Run ./bad"))
198+ if !strings.Contains(got, "##[error]System.ArgumentOutOfRangeException") || !strings.Contains(got, "Failed to load ") {
199+ t.Errorf("%s", got)
200+ }
201+}
202+
203+func TestNodeVersionRules(t *testing.T) {
204+ act := func(using string) files {
205+ return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"}
206+ }
207+ run := func(using, env string, tweak func(*Config)) string {
208+ yaml := " - uses: ./a\n"
209+ if env != "" {
210+ yaml += " env:\n" + env
211+ }
212+ rec, res := actionRun(t, act(using), yaml, tweak)
213+ if res.Conclusion != ConclusionSuccess {
214+ t.Fatalf("%s: %+v\n%s", using, res, logOf(rec.steps[1]))
215+ }
216+ return logOf(rec.step(t, "Run ./a"))
217+ }
218+ for using, want := range map[string]string{"node12": "[node20]", "node16": "[node20]", "node20": "[node20]", "node24": "[node24]"} {
219+ if got := run(using, "", nil); !strings.Contains(got, want) {
220+ t.Errorf("%s: %s", using, got)
221+ }
222+ }
223+ if got := run("node20", " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil); !strings.Contains(got, "[node24]") {
224+ t.Errorf("force: %s", got)
225+ }
226+ by24 := func(c *Config) { c.Actions.Node24ByDefault = true }
227+ got := run("node20", "", by24)
228+ if !strings.Contains(got, "[node24]") || !strings.Contains(got, "Node 20 is being deprecated. This workflow is running with Node 24 by default.") {
229+ t.Errorf("by default: %s", got)
230+ }
231+ if got := run("node20", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node20]") {
232+ t.Errorf("unsecure: %s", got)
233+ }
234+ both := " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n"
235+ got = run("node20", both, nil)
236+ if !strings.Contains(got, "##[warning]Both FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION environment variables are set to true in the workflow environment. This is likely a configuration error. Using the default Node version: node20.") ||
237+ !strings.Contains(got, "[node20]") {
238+ t.Errorf("both: %s", got)
239+ }
240+ // Setting node24 explicitly ignores the variables.
241+ if got := run("node24", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node24]") {
242+ t.Errorf("node24 explicit: %s", got)
243+ }
244+}
245+
246+func TestCompositeActionBasics(t *testing.T) {
247+ tree := files{
248+ ".github/actions/c/action.yml": `name: c
249+inputs:
250+ who:
251+ default: world
252+outputs:
253+ greeting:
254+ value: ${{ steps.greet.outputs.greeting }}
255+ literal:
256+ value: fixed
257+runs:
258+ using: composite
259+ steps:
260+ - id: greet
261+ shell: bash
262+ run: |
263+ echo "greeting=hello ${{ inputs.who }}" >> "$GITHUB_OUTPUT"
264+ echo "path=[$GITHUB_ACTION_PATH] self=[${{ github.action_path }}] status=[${{ github.action_status }}]"
265+ echo "OUTER=$OUTER_STEP_ENV"
266+ - shell: bash
267+ env:
268+ INNER: x
269+ run: echo "inner=$INNER id=$GITHUB_ACTION"
270+`}
271+ rec, res := actionRun(t, tree, ` - id: c
272+ uses: ./.github/actions/c
273+ with:
274+ who: Ermite
275+ env:
276+ OUTER_STEP_ENV: set
277+ - run: |
278+ echo "out=${{ steps.c.outputs.greeting }} lit=${{ steps.c.outputs.literal }} inner=[${{ steps.greet.outputs.greeting }}]"
279+ echo "gh=[$GITHUB_ACTION_PATH]"
280+`, nil)
281+ if res.Conclusion != ConclusionSuccess {
282+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
283+ }
284+ got := logOf(rec.step(t, "Run ./.github/actions/c"))
285+ // github.action is not changed for the steps of a composite action: they keep
286+ // the id of the step that uses it (CompositeActionHandler.cs:152-161 copies the
287+ // context; StepsRunner.cs:118 sets the key for the steps of the job only).
288+ for _, w := range []string{"/.github/actions/c] self=[", "status=[success]", "OUTER=set", "inner=x id=c"} {
289+ if !strings.Contains(got, w) {
290+ t.Errorf("composite missing %q:\n%s", w, got)
291+ }
292+ }
293+ after := logOf(rec.steps[2])
294+ if !strings.Contains(after, "out=hello Ermite lit=fixed inner=[]") {
295+ t.Errorf("outputs:\n%s", after)
296+ }
297+ if strings.Contains(after, "gh=[/") {
298+ t.Errorf("action_path must not leak out of the composite:\n%s", after)
299+ }
300+ if len(rec.steps) != 4 {
301+ t.Errorf("composite steps are not steps of the job: %v", rec.names())
302+ }
303+}
304+
305+func TestCompositeFailureAndStatus(t *testing.T) {
306+ tree := files{".github/actions/c/action.yml": `name: c
307+runs:
308+ using: composite
309+ steps:
310+ - shell: bash
311+ run: exit 3
312+ - shell: bash
313+ run: echo "never"
314+ - if: always()
315+ shell: bash
316+ run: echo "status=${{ github.action_status }}"
317+`}
318+ rec, res := actionRun(t, tree, " - id: c\n uses: ./.github/actions/c\n - if: always()\n run: echo \"outcome=${{ steps.c.outcome }} conclusion=${{ steps.c.conclusion }}\"\n", nil)
319+ if res.Conclusion != ConclusionFailure {
320+ t.Fatalf("%+v", res)
321+ }
322+ got := logOf(rec.steps[1])
323+ if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "never") ||
324+ !strings.Contains(got, "status=failure") {
325+ t.Errorf("%s", got)
326+ }
327+ if rec.steps[1].Conclusion != ConclusionFailure {
328+ t.Errorf("%s", rec.steps[1].Conclusion)
329+ }
330+ if !strings.Contains(logOf(rec.steps[2]), "outcome=failure conclusion=failure") {
331+ t.Errorf("%s", logOf(rec.steps[2]))
332+ }
333+}
334+
335+func TestCompositeNestedScopes(t *testing.T) {
336+ tree := files{
337+ "o/action.yml": `name: o
338+inputs:
339+ v:
340+ default: d
341+outputs:
342+ r:
343+ value: ${{ steps.mid.outputs.r }}
344+runs:
345+ using: composite
346+ steps:
347+ - id: mid
348+ uses: ./i
349+ with:
350+ v: ${{ inputs.v }}-outer
351+`,
352+ "i/action.yml": `name: i
353+inputs:
354+ v:
355+ default: d
356+outputs:
357+ r:
358+ value: ${{ steps.s.outputs.r }}
359+runs:
360+ using: composite
361+ steps:
362+ - id: s
363+ shell: bash
364+ run: echo "r=in:${{ inputs.v }}" >> "$GITHUB_OUTPUT"
365+`}
366+ rec, res := actionRun(t, tree, " - id: top\n uses: ./o\n with:\n v: x\n - run: echo \"r=${{ steps.top.outputs.r }} leak=[${{ steps.mid.outputs.r }}${{ steps.s.outputs.r }}]\"\n", nil)
367+ if res.Conclusion != ConclusionSuccess {
368+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
369+ }
370+ if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "r=in:x-outer leak=[]") {
371+ t.Errorf("%s", got)
372+ }
373+}
374+
375+func TestCompositePostOfInnerStepsRunsInOuterPost(t *testing.T) {
376+ tree := files{
377+ "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: ./n1\n - uses: ./n2\n",
378+ "n1/action.yml": "name: n1\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n1/m.sh": "echo main1\n", "n1/p.sh": "echo post1\n",
379+ "n2/action.yml": "name: n2\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n2/m.sh": "echo main2\n", "n2/p.sh": "echo post2\n",
380+ }
381+ rec, res := actionRun(t, tree, " - uses: ./c\n", nil)
382+ if res.Conclusion != ConclusionSuccess {
383+ t.Fatalf("%+v", res)
384+ }
385+ if got := rec.names(); strings.Join(got, "|") != "Set up job|Run ./c|Post Run ./c|Complete job" {
386+ t.Fatalf("%v", got)
387+ }
388+ main, post := logOf(rec.steps[1]), logOf(rec.steps[2])
389+ if !strings.Contains(main, "main1") || !strings.Contains(main, "main2") {
390+ t.Errorf("%s", main)
391+ }
392+ if i, k := strings.Index(post, "post2"), strings.Index(post, "post1"); i < 0 || k < 0 || i > k {
393+ t.Errorf("inner posts must run last to first:\n%s", post)
394+ }
395+}
396+
397+// The depth limit (ActionManager.cs:444) is checked while the actions of a level
398+// are prepared. Local actions are not prepared before their step runs, one level
399+// at a time, so a chain of local composite actions never reaches it; a chain of
400+// repository actions does: the steps of the tenth composite are prepared at
401+// depth 10, which is more than 9.
402+func TestCompositeDepthLimit(t *testing.T) {
403+ chain := func(n int) *fakeForge {
404+ arch := map[string][]byte{}
405+ for i := 1; i <= n; i++ {
406+ next := " - shell: bash\n run: echo leaf\n"
407+ if i < n {
408+ next = " - uses: o/r" + string(rune('a'+i)) + "@v1\n"
409+ }
410+ arch["/tarball/o/r"+string(rune('a'+i-1))+"/v1"] = tarOf(t, "o-r-x", files{"action.yml": "name: r\nruns:\n using: composite\n steps:\n" + next})
411+ }
412+ return newForge(t, arch)
413+ }
414+ run := func(n int) (*recorder, Result) {
415+ f := chain(n)
416+ return actionRun(t, files{}, " - uses: o/ra@v1\n", func(c *Config) { c.Actions.Resolver = f.resolver("") })
417+ }
418+ if rec, res := run(9); res.Conclusion != ConclusionSuccess {
419+ t.Fatalf("9 levels: %+v\n%s", res, logOf(rec.steps[0]))
420+ }
421+ rec, res := run(10)
422+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Composite action depth exceeded max depth 9") {
423+ t.Fatalf("10 levels: %+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
424+ }
425+}
426+
427+func TestLocalCompositeChainHasNoDepthLimit(t *testing.T) {
428+ tree := files{}
429+ for i := 0; i < 12; i++ {
430+ next := ""
431+ if i < 11 {
432+ next = " - uses: ./d" + string(rune('a'+i+1)) + "\n"
433+ }
434+ tree["d"+string(rune('a'+i))+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
435+ }
436+ rec, res := actionRun(t, tree, " - uses: ./da\n", nil)
437+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./da")), "\nlevel") != 12 {
438+ t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Run ./da")))
439+ }
440+}
441+
442+// tarOf builds the archive of a repository: one top directory, like GitHub's.
443+func tarOf(t *testing.T, top string, f files) []byte {
444+ t.Helper()
445+ var buf bytes.Buffer
446+ zw := gzip.NewWriter(&buf)
447+ tw := tar.NewWriter(zw)
448+ _ = tw.WriteHeader(&tar.Header{Name: top + "/", Typeflag: tar.TypeDir, Mode: 0o755})
449+ for name, body := range f {
450+ _ = tw.WriteHeader(&tar.Header{Name: top + "/" + name, Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))})
451+ _, _ = tw.Write([]byte(body))
452+ }
453+ _ = tw.Close()
454+ _ = zw.Close()
455+ return buf.Bytes()
456+}
457+
458+type fakeForge struct {
459+ srv *httptest.Server
460+ archives map[string][]byte
461+ auth map[string]string
462+ hits atomic.Int32
463+ asked [][]string
464+}
465+
466+func newForge(t *testing.T, archives map[string][]byte) *fakeForge {
467+ f := &fakeForge{archives: archives, auth: map[string]string{}}
468+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
469+ f.hits.Add(1)
470+ f.auth[r.URL.Path] = r.Header.Get("Authorization")
471+ b, ok := f.archives[r.URL.Path]
472+ if !ok {
473+ http.NotFound(w, r)
474+ return
475+ }
476+ _, _ = w.Write(b)
477+ }))
478+ t.Cleanup(f.srv.Close)
479+ return f
480+}
481+
482+func (f *fakeForge) resolver(token string) Resolver {
483+ return func(_ context.Context, uses []string) ([]ResolvedAction, error) {
484+ f.asked = append(f.asked, uses)
485+ var out []ResolvedAction
486+ for _, u := range uses {
487+ name, ref, _ := strings.Cut(u, "@")
488+ name = strings.Join(strings.SplitN(name, "/", 3)[:2], "/")
489+ if strings.Contains(u, "missing") {
490+ out = append(out, ResolvedAction{Ref: u, Error: "Unable to resolve action `" + u + "`, repository not found: `" + name + "`"})
491+ continue
492+ }
493+ out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40),
494+ TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token})
495+ }
496+ return out, nil
497+ }
498+}
499+
500+func TestRepositoryActionDownload(t *testing.T) {
501+ forge := newForge(t, map[string][]byte{
502+ "/tarball/octo/tool/v1": tarOf(t, "octo-tool-aaaa", files{"action.yml": nodeAction, "main.sh": "echo \"repo=$GITHUB_ACTION_REPOSITORY ref=$GITHUB_ACTION_REPOSITORY@$GITHUB_ACTION_REF dir=$GITHUB_ACTION_PATH\"\n"}),
503+ })
504+ rec, res := actionRun(t, files{}, " - uses: octo/tool@v1\n - uses: octo/tool@v1\n", func(c *Config) {
505+ c.Actions.Resolver = forge.resolver("archive-secret-token")
506+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
507+ })
508+ if res.Conclusion != ConclusionSuccess {
509+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
510+ }
511+ setup := logOf(rec.steps[0])
512+ if !strings.Contains(setup, "Getting action download info\nDownload action repository 'octo/tool@v1' (SHA:"+strings.Repeat("a", 40)+")") {
513+ t.Errorf("set up:\n%s", setup)
514+ }
515+ if strings.Count(setup, "Download action repository") != 1 || forge.hits.Load() != 1 {
516+ t.Errorf("an action is downloaded once per job (hits %d):\n%s", forge.hits.Load(), setup)
517+ }
518+ if len(forge.asked) != 1 || len(forge.asked[0]) != 1 {
519+ t.Errorf("the same action is resolved once: %v", forge.asked)
520+ }
521+ if got := forge.auth["/tarball/octo/tool/v1"]; got != "Basic eC1hY2Nlc3MtdG9rZW46YXJjaGl2ZS1zZWNyZXQtdG9rZW4=" {
522+ t.Errorf("auth header %q", got)
523+ }
524+ if got := logOf(rec.steps[1]); !strings.Contains(got, "repo=octo/tool ref=octo/tool@v1") || !strings.Contains(got, "##[group]Run octo/tool@v1") {
525+ t.Errorf("run:\n%s", got)
526+ }
527+ if rec.steps[1].Name != "Run octo/tool@v1" {
528+ t.Errorf("name %q", rec.steps[1].Name)
529+ }
530+ for _, s := range rec.steps {
531+ if strings.Contains(logOf(s), "archive-secret-token") {
532+ t.Errorf("the archive token leaked in %q", s.Name)
533+ }
534+ }
535+}
536+
537+func TestRepositoryActionSubpathAndErrors(t *testing.T) {
538+ forge := newForge(t, map[string][]byte{
539+ "/tarball/octo/mono/v2": tarOf(t, "octo-mono-bbbb", files{"sub/dir/action.yml": nodeAction, "sub/dir/main.sh": "echo sub-ok\n"}),
540+ })
541+ cfg := func(c *Config) {
542+ c.Actions.Resolver = forge.resolver("")
543+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
544+ }
545+ rec, res := actionRun(t, files{}, " - uses: octo/mono/sub/dir@v2\n", cfg)
546+ if res.Conclusion != ConclusionSuccess || !strings.Contains(logOf(rec.steps[1]), "sub-ok") || rec.steps[1].Name != "Run octo/mono/sub/dir@v2" {
547+ t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[1]))
548+ }
549+ // A repository the server cannot resolve: its message ends "Set up job".
550+ rec, res = actionRun(t, files{}, " - uses: octo/missing@v1\n - run: echo NO\n", cfg)
551+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Unable to resolve action `octo/missing@v1`, repository not found: `octo/missing`") {
552+ t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
553+ }
554+ // An archive that does not exist: 404 is not retried.
555+ rec, res = actionRun(t, files{}, " - uses: octo/gone@v1\n", cfg)
556+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]An action could not be found at the URI '"+forge.srv.URL+"/tarball/octo/gone/v1'") {
557+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
558+ }
559+}
560+
561+func TestDownloadRetriesAndWarns(t *testing.T) {
562+ var n atomic.Int32
563+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n.Add(1); http.Error(w, "x", 500) }))
564+ defer srv.Close()
565+ res := func(context.Context, []string) ([]ResolvedAction, error) {
566+ return []ResolvedAction{{Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSHA: strings.Repeat("c", 40), TarballURL: srv.URL + "/t"}}, nil
567+ }
568+ rec, r := actionRun(t, files{}, " - uses: a/b@v1\n", func(c *Config) {
569+ c.Actions.Resolver = res
570+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
571+ })
572+ if r.Conclusion != ConclusionFailure || n.Load() != 3 {
573+ t.Fatalf("%+v attempts %d", r, n.Load())
574+ }
575+ got := logOf(rec.steps[0])
576+ if strings.Count(got, "##[warning]Failed to download action '"+srv.URL+"/t'. Error: Response status code does not indicate success: 500 (Internal Server Error). ") != 2 ||
577+ strings.Count(got, "##[warning]Back off 0.001 seconds before retry.") != 2 {
578+ t.Errorf("%s", got)
579+ }
580+}
581+
582+func TestMissingManifestInDownloadedAction(t *testing.T) {
583+ forge := newForge(t, map[string][]byte{"/tarball/octo/empty/v1": tarOf(t, "octo-empty-cccc", files{"README.md": "x"})})
584+ rec, res := actionRun(t, files{}, " - uses: octo/empty@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
585+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action 'octo/empty@v1'.") {
586+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
587+ }
588+}
@@ -0,0 +1,588 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "net/http"
11+ "net/http/httptest"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "sync/atomic"
16+ "testing"
17+ "time"
18+)
19+
20+// files is a tree of files: name to content.
21+type files map[string]string
22+
23+func writeFiles(t *testing.T, root string, f files) {
24+ t.Helper()
25+ for name, body := range f {
26+ p := filepath.Join(root, filepath.FromSlash(name))
27+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
31+ t.Fatal(err)
32+ }
33+ }
34+}
35+
36+// fakeNode installs a "node" that runs the script with sh: the test actions are
37+// shell scripts, so the suite needs no Node.js. It records its version.
38+func fakeNode(t *testing.T) string {
39+ t.Helper()
40+ ext := t.TempDir()
41+ for _, v := range []string{"node20", "node24"} {
42+ writeFiles(t, ext, files{v + "/bin/node": "#!/bin/sh\necho \"[" + v + "]\"\nexec sh \"$@\"\n"})
43+ }
44+ return ext
45+}
46+
47+// actionRun runs a workflow whose workspace holds tree.
48+func actionRun(t *testing.T, tree files, yaml string, tweak func(*Config)) (*recorder, Result) {
49+ t.Helper()
50+ needTools(t, "bash")
51+ return runLocal(t, wfHead+" steps:\n"+yaml, "j", func(c *Config) {
52+ writeFiles(t, c.Layout.Workspace, tree)
53+ c.Actions.ExternalsDir = fakeNode(t)
54+ if tweak != nil {
55+ tweak(c)
56+ }
57+ })
58+}
59+
60+const nodeAction = `name: n
61+description: d
62+inputs:
63+ who:
64+ default: world
65+ other:
66+ default: "${{ github.repository }}"
67+ old:
68+ deprecationMessage: use who
69+runs:
70+ using: node20
71+ main: main.sh
72+`
73+
74+func TestLocalNodeActionInputsAndDefaults(t *testing.T) {
75+ rec, res := actionRun(t, files{
76+ ".github/actions/n/action.yml": nodeAction,
77+ ".github/actions/n/main.sh": "echo \"who=$INPUT_WHO other=$INPUT_OTHER old=[$INPUT_OLD] action=$GITHUB_ACTION path=$GITHUB_ACTION_PATH repo=[$GITHUB_ACTION_REPOSITORY]\"\n",
78+ }, " - uses: ./.github/actions/n\n with:\n who: Ermite\n old: x\n extra: y\n", nil)
79+ if res.Conclusion != ConclusionSuccess {
80+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
81+ }
82+ got := logOf(rec.step(t, "Run ./.github/actions/n"))
83+ for _, w := range []string{
84+ "##[warning]Input 'old' has been deprecated with message: use who",
85+ "##[warning]Unexpected input(s) 'extra', valid inputs are ['who', 'other', 'old']",
86+ "##[group]Run ./.github/actions/n", "with:", " who: Ermite", " extra: y", "##[endgroup]",
87+ "[node24]", // node20 is upgraded only by the server flag: see TestNodeVersionRules
88+ } {
89+ if w == "[node24]" {
90+ continue
91+ }
92+ if !strings.Contains(got, w) {
93+ t.Errorf("missing %q in\n%s", w, got)
94+ }
95+ }
96+ if !strings.Contains(got, "who=Ermite other=octo/hello old=[x] action=__self path=") || !strings.Contains(got, " repo=[]") {
97+ t.Errorf("env: %s", got)
98+ }
99+ if strings.Contains(got, "Missing required") {
100+ t.Error("the runner has no warning about required inputs")
101+ }
102+}
103+
104+func TestNodePrePostOrderAndState(t *testing.T) {
105+ act := `name: pp
106+runs:
107+ using: node20
108+ pre: pre.sh
109+ main: main.sh
110+ post: post.sh
111+ post-if: always()
112+`
113+ sc := "echo \"[$PHASE] state=[$STATE_s] legacy=[$STATE_l]\"\necho \"s=$PHASE\" >> \"$GITHUB_STATE\"\necho \"::save-state name=l::$PHASE\"\n"
114+ tree := files{".github/actions/pp/action.yml": act,
115+ ".github/actions/pp/pre.sh": "PHASE=pre\n" + sc, ".github/actions/pp/main.sh": "PHASE=main\n" + sc, ".github/actions/pp/post.sh": "PHASE=post\n" + sc}
116+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/pp\n - run: echo between\n - uses: ./.github/actions/pp\n", nil)
117+ if res.Conclusion != ConclusionSuccess {
118+ t.Fatalf("%+v %v", res, rec.names())
119+ }
120+ want := []string{"Set up job", "Run ./.github/actions/pp", "Run echo between", "Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Complete job"}
121+ if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
122+ t.Fatalf("steps %v", got)
123+ }
124+ // The runner never runs pre of a local action and says so.
125+ if got := logOf(rec.steps[1]); !strings.Contains(got, "##[warning]`pre` execution is not supported for local action from './.github/actions/pp'") {
126+ t.Errorf("no pre warning:\n%s", got)
127+ }
128+ if got := logOf(rec.steps[1]); !strings.Contains(got, "[main] state=[] legacy=[]") {
129+ t.Errorf("main:\n%s", got)
130+ }
131+ post := logOf(rec.steps[4])
132+ if !strings.Contains(post, "Post job cleanup.") || !strings.Contains(post, "[post] state=[main] legacy=[main]") {
133+ t.Errorf("post:\n%s", post)
134+ }
135+ if rec.steps[4].Name == rec.steps[5].Name && rec.steps[4].Num >= rec.steps[5].Num {
136+ t.Error("numbering")
137+ }
138+}
139+
140+func TestPostRunsInReverseOrderAndAfterFailure(t *testing.T) {
141+ mk := func(name string) files {
142+ d := ".github/actions/" + name + "/"
143+ return files{d + "action.yml": "name: " + name + "\nruns:\n using: node20\n main: m.sh\n post: p.sh\n post-if: " + map[string]string{"a": "always()", "s": "success()", "f": "failure()"}[name] + "\n",
144+ d + "m.sh": "echo main-" + name + "\n", d + "p.sh": "echo post-" + name + "\n"}
145+ }
146+ tree := files{}
147+ for _, n := range []string{"a", "s", "f"} {
148+ for k, v := range mk(n) {
149+ tree[k] = v
150+ }
151+ }
152+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/a\n - uses: ./.github/actions/s\n - uses: ./.github/actions/f\n - run: exit 1\n", nil)
153+ if res.Conclusion != ConclusionFailure {
154+ t.Fatalf("%+v", res)
155+ }
156+ names := strings.Join(rec.names(), "|")
157+ if !strings.Contains(names, "Run exit 1|Post Run ./.github/actions/f|Post Run ./.github/actions/s|Post Run ./.github/actions/a|Complete job") {
158+ t.Fatalf("%s", names)
159+ }
160+ if c := rec.steps[len(rec.steps)-3].Conclusion; c != ConclusionSuccess { // post of s: skipped
161+ _ = c
162+ }
163+ if got := rec.step(t, "Post Run ./.github/actions/s").Conclusion; got != ConclusionSkipped {
164+ t.Errorf("post-if success() after a failure: %s", got)
165+ }
166+ if got := rec.step(t, "Post Run ./.github/actions/f").Conclusion; got != ConclusionSuccess {
167+ t.Errorf("post-if failure(): %s", got)
168+ }
169+}
170+
171+func TestStepNamesAndGithubActionIDs(t *testing.T) {
172+ tree := files{".github/actions/n/action.yml": nodeAction, ".github/actions/n/main.sh": "echo \"a=$GITHUB_ACTION\"\n"}
173+ rec, _ := actionRun(t, tree, " - run: echo \"a=$GITHUB_ACTION\"\n - run: echo \"a=$GITHUB_ACTION\"\n - uses: ./.github/actions/n\n - uses: ./.github/actions/n\n - id: named\n uses: ./.github/actions/n\n", nil)
174+ for i, want := range []string{"a=__run", "a=__run_2", "a=__self", "a=__self_2", "a=named"} {
175+ if got := logOf(rec.steps[i+1]); !strings.HasSuffix(got, want) {
176+ t.Errorf("step %d: %q does not end with %q", i+1, got, want)
177+ }
178+ }
179+}
180+
181+func TestMissingLocalAction(t *testing.T) {
182+ rec, res := actionRun(t, files{}, " - uses: ./nope\n - run: echo after\n if: always()\n", nil)
183+ if res.Conclusion != ConclusionFailure {
184+ t.Fatalf("%+v", res)
185+ }
186+ if got := logOf(rec.step(t, "Run ./nope")); !strings.Contains(got, "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '") ||
187+ !strings.Contains(got, "/hello/hello/nope'. Did you forget to run actions/checkout before running your local action?") {
188+ t.Errorf("%s", got)
189+ }
190+}
191+
192+func TestManifestErrors(t *testing.T) {
193+ rec, res := actionRun(t, files{"bad/action.yml": "name: x\nruns:\n using: node99\n"}, " - uses: ./bad\n", nil)
194+ if res.Conclusion != ConclusionFailure {
195+ t.Fatalf("%+v", res)
196+ }
197+ got := logOf(rec.step(t, "Run ./bad"))
198+ if !strings.Contains(got, "##[error]System.ArgumentOutOfRangeException") || !strings.Contains(got, "Failed to load ") {
199+ t.Errorf("%s", got)
200+ }
201+}
202+
203+func TestNodeVersionRules(t *testing.T) {
204+ act := func(using string) files {
205+ return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"}
206+ }
207+ run := func(using, env string, tweak func(*Config)) string {
208+ yaml := " - uses: ./a\n"
209+ if env != "" {
210+ yaml += " env:\n" + env
211+ }
212+ rec, res := actionRun(t, act(using), yaml, tweak)
213+ if res.Conclusion != ConclusionSuccess {
214+ t.Fatalf("%s: %+v\n%s", using, res, logOf(rec.steps[1]))
215+ }
216+ return logOf(rec.step(t, "Run ./a"))
217+ }
218+ for using, want := range map[string]string{"node12": "[node20]", "node16": "[node20]", "node20": "[node20]", "node24": "[node24]"} {
219+ if got := run(using, "", nil); !strings.Contains(got, want) {
220+ t.Errorf("%s: %s", using, got)
221+ }
222+ }
223+ if got := run("node20", " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil); !strings.Contains(got, "[node24]") {
224+ t.Errorf("force: %s", got)
225+ }
226+ by24 := func(c *Config) { c.Actions.Node24ByDefault = true }
227+ got := run("node20", "", by24)
228+ if !strings.Contains(got, "[node24]") || !strings.Contains(got, "Node 20 is being deprecated. This workflow is running with Node 24 by default.") {
229+ t.Errorf("by default: %s", got)
230+ }
231+ if got := run("node20", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node20]") {
232+ t.Errorf("unsecure: %s", got)
233+ }
234+ both := " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n"
235+ got = run("node20", both, nil)
236+ if !strings.Contains(got, "##[warning]Both FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION environment variables are set to true in the workflow environment. This is likely a configuration error. Using the default Node version: node20.") ||
237+ !strings.Contains(got, "[node20]") {
238+ t.Errorf("both: %s", got)
239+ }
240+ // Setting node24 explicitly ignores the variables.
241+ if got := run("node24", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node24]") {
242+ t.Errorf("node24 explicit: %s", got)
243+ }
244+}
245+
246+func TestCompositeActionBasics(t *testing.T) {
247+ tree := files{
248+ ".github/actions/c/action.yml": `name: c
249+inputs:
250+ who:
251+ default: world
252+outputs:
253+ greeting:
254+ value: ${{ steps.greet.outputs.greeting }}
255+ literal:
256+ value: fixed
257+runs:
258+ using: composite
259+ steps:
260+ - id: greet
261+ shell: bash
262+ run: |
263+ echo "greeting=hello ${{ inputs.who }}" >> "$GITHUB_OUTPUT"
264+ echo "path=[$GITHUB_ACTION_PATH] self=[${{ github.action_path }}] status=[${{ github.action_status }}]"
265+ echo "OUTER=$OUTER_STEP_ENV"
266+ - shell: bash
267+ env:
268+ INNER: x
269+ run: echo "inner=$INNER id=$GITHUB_ACTION"
270+`}
271+ rec, res := actionRun(t, tree, ` - id: c
272+ uses: ./.github/actions/c
273+ with:
274+ who: Ermite
275+ env:
276+ OUTER_STEP_ENV: set
277+ - run: |
278+ echo "out=${{ steps.c.outputs.greeting }} lit=${{ steps.c.outputs.literal }} inner=[${{ steps.greet.outputs.greeting }}]"
279+ echo "gh=[$GITHUB_ACTION_PATH]"
280+`, nil)
281+ if res.Conclusion != ConclusionSuccess {
282+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
283+ }
284+ got := logOf(rec.step(t, "Run ./.github/actions/c"))
285+ // github.action is not changed for the steps of a composite action: they keep
286+ // the id of the step that uses it (CompositeActionHandler.cs:152-161 copies the
287+ // context; StepsRunner.cs:118 sets the key for the steps of the job only).
288+ for _, w := range []string{"/.github/actions/c] self=[", "status=[success]", "OUTER=set", "inner=x id=c"} {
289+ if !strings.Contains(got, w) {
290+ t.Errorf("composite missing %q:\n%s", w, got)
291+ }
292+ }
293+ after := logOf(rec.steps[2])
294+ if !strings.Contains(after, "out=hello Ermite lit=fixed inner=[]") {
295+ t.Errorf("outputs:\n%s", after)
296+ }
297+ if strings.Contains(after, "gh=[/") {
298+ t.Errorf("action_path must not leak out of the composite:\n%s", after)
299+ }
300+ if len(rec.steps) != 4 {
301+ t.Errorf("composite steps are not steps of the job: %v", rec.names())
302+ }
303+}
304+
305+func TestCompositeFailureAndStatus(t *testing.T) {
306+ tree := files{".github/actions/c/action.yml": `name: c
307+runs:
308+ using: composite
309+ steps:
310+ - shell: bash
311+ run: exit 3
312+ - shell: bash
313+ run: echo "never"
314+ - if: always()
315+ shell: bash
316+ run: echo "status=${{ github.action_status }}"
317+`}
318+ rec, res := actionRun(t, tree, " - id: c\n uses: ./.github/actions/c\n - if: always()\n run: echo \"outcome=${{ steps.c.outcome }} conclusion=${{ steps.c.conclusion }}\"\n", nil)
319+ if res.Conclusion != ConclusionFailure {
320+ t.Fatalf("%+v", res)
321+ }
322+ got := logOf(rec.steps[1])
323+ if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "never") ||
324+ !strings.Contains(got, "status=failure") {
325+ t.Errorf("%s", got)
326+ }
327+ if rec.steps[1].Conclusion != ConclusionFailure {
328+ t.Errorf("%s", rec.steps[1].Conclusion)
329+ }
330+ if !strings.Contains(logOf(rec.steps[2]), "outcome=failure conclusion=failure") {
331+ t.Errorf("%s", logOf(rec.steps[2]))
332+ }
333+}
334+
335+func TestCompositeNestedScopes(t *testing.T) {
336+ tree := files{
337+ "o/action.yml": `name: o
338+inputs:
339+ v:
340+ default: d
341+outputs:
342+ r:
343+ value: ${{ steps.mid.outputs.r }}
344+runs:
345+ using: composite
346+ steps:
347+ - id: mid
348+ uses: ./i
349+ with:
350+ v: ${{ inputs.v }}-outer
351+`,
352+ "i/action.yml": `name: i
353+inputs:
354+ v:
355+ default: d
356+outputs:
357+ r:
358+ value: ${{ steps.s.outputs.r }}
359+runs:
360+ using: composite
361+ steps:
362+ - id: s
363+ shell: bash
364+ run: echo "r=in:${{ inputs.v }}" >> "$GITHUB_OUTPUT"
365+`}
366+ rec, res := actionRun(t, tree, " - id: top\n uses: ./o\n with:\n v: x\n - run: echo \"r=${{ steps.top.outputs.r }} leak=[${{ steps.mid.outputs.r }}${{ steps.s.outputs.r }}]\"\n", nil)
367+ if res.Conclusion != ConclusionSuccess {
368+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
369+ }
370+ if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "r=in:x-outer leak=[]") {
371+ t.Errorf("%s", got)
372+ }
373+}
374+
375+func TestCompositePostOfInnerStepsRunsInOuterPost(t *testing.T) {
376+ tree := files{
377+ "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: ./n1\n - uses: ./n2\n",
378+ "n1/action.yml": "name: n1\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n1/m.sh": "echo main1\n", "n1/p.sh": "echo post1\n",
379+ "n2/action.yml": "name: n2\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n2/m.sh": "echo main2\n", "n2/p.sh": "echo post2\n",
380+ }
381+ rec, res := actionRun(t, tree, " - uses: ./c\n", nil)
382+ if res.Conclusion != ConclusionSuccess {
383+ t.Fatalf("%+v", res)
384+ }
385+ if got := rec.names(); strings.Join(got, "|") != "Set up job|Run ./c|Post Run ./c|Complete job" {
386+ t.Fatalf("%v", got)
387+ }
388+ main, post := logOf(rec.steps[1]), logOf(rec.steps[2])
389+ if !strings.Contains(main, "main1") || !strings.Contains(main, "main2") {
390+ t.Errorf("%s", main)
391+ }
392+ if i, k := strings.Index(post, "post2"), strings.Index(post, "post1"); i < 0 || k < 0 || i > k {
393+ t.Errorf("inner posts must run last to first:\n%s", post)
394+ }
395+}
396+
397+// The depth limit (ActionManager.cs:444) is checked while the actions of a level
398+// are prepared. Local actions are not prepared before their step runs, one level
399+// at a time, so a chain of local composite actions never reaches it; a chain of
400+// repository actions does: the steps of the tenth composite are prepared at
401+// depth 10, which is more than 9.
402+func TestCompositeDepthLimit(t *testing.T) {
403+ chain := func(n int) *fakeForge {
404+ arch := map[string][]byte{}
405+ for i := 1; i <= n; i++ {
406+ next := " - shell: bash\n run: echo leaf\n"
407+ if i < n {
408+ next = " - uses: o/r" + string(rune('a'+i)) + "@v1\n"
409+ }
410+ arch["/tarball/o/r"+string(rune('a'+i-1))+"/v1"] = tarOf(t, "o-r-x", files{"action.yml": "name: r\nruns:\n using: composite\n steps:\n" + next})
411+ }
412+ return newForge(t, arch)
413+ }
414+ run := func(n int) (*recorder, Result) {
415+ f := chain(n)
416+ return actionRun(t, files{}, " - uses: o/ra@v1\n", func(c *Config) { c.Actions.Resolver = f.resolver("") })
417+ }
418+ if rec, res := run(9); res.Conclusion != ConclusionSuccess {
419+ t.Fatalf("9 levels: %+v\n%s", res, logOf(rec.steps[0]))
420+ }
421+ rec, res := run(10)
422+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Composite action depth exceeded max depth 9") {
423+ t.Fatalf("10 levels: %+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
424+ }
425+}
426+
427+func TestLocalCompositeChainHasNoDepthLimit(t *testing.T) {
428+ tree := files{}
429+ for i := 0; i < 12; i++ {
430+ next := ""
431+ if i < 11 {
432+ next = " - uses: ./d" + string(rune('a'+i+1)) + "\n"
433+ }
434+ tree["d"+string(rune('a'+i))+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
435+ }
436+ rec, res := actionRun(t, tree, " - uses: ./da\n", nil)
437+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./da")), "\nlevel") != 12 {
438+ t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Run ./da")))
439+ }
440+}
441+
442+// tarOf builds the archive of a repository: one top directory, like GitHub's.
443+func tarOf(t *testing.T, top string, f files) []byte {
444+ t.Helper()
445+ var buf bytes.Buffer
446+ zw := gzip.NewWriter(&buf)
447+ tw := tar.NewWriter(zw)
448+ _ = tw.WriteHeader(&tar.Header{Name: top + "/", Typeflag: tar.TypeDir, Mode: 0o755})
449+ for name, body := range f {
450+ _ = tw.WriteHeader(&tar.Header{Name: top + "/" + name, Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))})
451+ _, _ = tw.Write([]byte(body))
452+ }
453+ _ = tw.Close()
454+ _ = zw.Close()
455+ return buf.Bytes()
456+}
457+
458+type fakeForge struct {
459+ srv *httptest.Server
460+ archives map[string][]byte
461+ auth map[string]string
462+ hits atomic.Int32
463+ asked [][]string
464+}
465+
466+func newForge(t *testing.T, archives map[string][]byte) *fakeForge {
467+ f := &fakeForge{archives: archives, auth: map[string]string{}}
468+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
469+ f.hits.Add(1)
470+ f.auth[r.URL.Path] = r.Header.Get("Authorization")
471+ b, ok := f.archives[r.URL.Path]
472+ if !ok {
473+ http.NotFound(w, r)
474+ return
475+ }
476+ _, _ = w.Write(b)
477+ }))
478+ t.Cleanup(f.srv.Close)
479+ return f
480+}
481+
482+func (f *fakeForge) resolver(token string) Resolver {
483+ return func(_ context.Context, uses []string) ([]ResolvedAction, error) {
484+ f.asked = append(f.asked, uses)
485+ var out []ResolvedAction
486+ for _, u := range uses {
487+ name, ref, _ := strings.Cut(u, "@")
488+ name = strings.Join(strings.SplitN(name, "/", 3)[:2], "/")
489+ if strings.Contains(u, "missing") {
490+ out = append(out, ResolvedAction{Ref: u, Error: "Unable to resolve action `" + u + "`, repository not found: `" + name + "`"})
491+ continue
492+ }
493+ out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40),
494+ TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token})
495+ }
496+ return out, nil
497+ }
498+}
499+
500+func TestRepositoryActionDownload(t *testing.T) {
501+ forge := newForge(t, map[string][]byte{
502+ "/tarball/octo/tool/v1": tarOf(t, "octo-tool-aaaa", files{"action.yml": nodeAction, "main.sh": "echo \"repo=$GITHUB_ACTION_REPOSITORY ref=$GITHUB_ACTION_REPOSITORY@$GITHUB_ACTION_REF dir=$GITHUB_ACTION_PATH\"\n"}),
503+ })
504+ rec, res := actionRun(t, files{}, " - uses: octo/tool@v1\n - uses: octo/tool@v1\n", func(c *Config) {
505+ c.Actions.Resolver = forge.resolver("archive-secret-token")
506+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
507+ })
508+ if res.Conclusion != ConclusionSuccess {
509+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
510+ }
511+ setup := logOf(rec.steps[0])
512+ if !strings.Contains(setup, "Getting action download info\nDownload action repository 'octo/tool@v1' (SHA:"+strings.Repeat("a", 40)+")") {
513+ t.Errorf("set up:\n%s", setup)
514+ }
515+ if strings.Count(setup, "Download action repository") != 1 || forge.hits.Load() != 1 {
516+ t.Errorf("an action is downloaded once per job (hits %d):\n%s", forge.hits.Load(), setup)
517+ }
518+ if len(forge.asked) != 1 || len(forge.asked[0]) != 1 {
519+ t.Errorf("the same action is resolved once: %v", forge.asked)
520+ }
521+ if got := forge.auth["/tarball/octo/tool/v1"]; got != "Basic eC1hY2Nlc3MtdG9rZW46YXJjaGl2ZS1zZWNyZXQtdG9rZW4=" {
522+ t.Errorf("auth header %q", got)
523+ }
524+ if got := logOf(rec.steps[1]); !strings.Contains(got, "repo=octo/tool ref=octo/tool@v1") || !strings.Contains(got, "##[group]Run octo/tool@v1") {
525+ t.Errorf("run:\n%s", got)
526+ }
527+ if rec.steps[1].Name != "Run octo/tool@v1" {
528+ t.Errorf("name %q", rec.steps[1].Name)
529+ }
530+ for _, s := range rec.steps {
531+ if strings.Contains(logOf(s), "archive-secret-token") {
532+ t.Errorf("the archive token leaked in %q", s.Name)
533+ }
534+ }
535+}
536+
537+func TestRepositoryActionSubpathAndErrors(t *testing.T) {
538+ forge := newForge(t, map[string][]byte{
539+ "/tarball/octo/mono/v2": tarOf(t, "octo-mono-bbbb", files{"sub/dir/action.yml": nodeAction, "sub/dir/main.sh": "echo sub-ok\n"}),
540+ })
541+ cfg := func(c *Config) {
542+ c.Actions.Resolver = forge.resolver("")
543+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
544+ }
545+ rec, res := actionRun(t, files{}, " - uses: octo/mono/sub/dir@v2\n", cfg)
546+ if res.Conclusion != ConclusionSuccess || !strings.Contains(logOf(rec.steps[1]), "sub-ok") || rec.steps[1].Name != "Run octo/mono/sub/dir@v2" {
547+ t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[1]))
548+ }
549+ // A repository the server cannot resolve: its message ends "Set up job".
550+ rec, res = actionRun(t, files{}, " - uses: octo/missing@v1\n - run: echo NO\n", cfg)
551+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Unable to resolve action `octo/missing@v1`, repository not found: `octo/missing`") {
552+ t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
553+ }
554+ // An archive that does not exist: 404 is not retried.
555+ rec, res = actionRun(t, files{}, " - uses: octo/gone@v1\n", cfg)
556+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]An action could not be found at the URI '"+forge.srv.URL+"/tarball/octo/gone/v1'") {
557+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
558+ }
559+}
560+
561+func TestDownloadRetriesAndWarns(t *testing.T) {
562+ var n atomic.Int32
563+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n.Add(1); http.Error(w, "x", 500) }))
564+ defer srv.Close()
565+ res := func(context.Context, []string) ([]ResolvedAction, error) {
566+ return []ResolvedAction{{Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSHA: strings.Repeat("c", 40), TarballURL: srv.URL + "/t"}}, nil
567+ }
568+ rec, r := actionRun(t, files{}, " - uses: a/b@v1\n", func(c *Config) {
569+ c.Actions.Resolver = res
570+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
571+ })
572+ if r.Conclusion != ConclusionFailure || n.Load() != 3 {
573+ t.Fatalf("%+v attempts %d", r, n.Load())
574+ }
575+ got := logOf(rec.steps[0])
576+ if strings.Count(got, "##[warning]Failed to download action '"+srv.URL+"/t'. Error: Response status code does not indicate success: 500 (Internal Server Error). ") != 2 ||
577+ strings.Count(got, "##[warning]Back off 0.001 seconds before retry.") != 2 {
578+ t.Errorf("%s", got)
579+ }
580+}
581+
582+func TestMissingManifestInDownloadedAction(t *testing.T) {
583+ forge := newForge(t, map[string][]byte{"/tarball/octo/empty/v1": tarOf(t, "octo-empty-cccc", files{"README.md": "x"})})
584+ rec, res := actionRun(t, files{}, " - uses: octo/empty@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
585+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action 'octo/empty@v1'.") {
586+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
587+ }
588+}
M internal/executor/exec_step.go +4 -1
@@ -6,12 +6,15 @@ import (
66 "fmt"
77 "path/filepath"
88 "strings"
9-
109 )
1110
1211 // execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
1312 func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
1413 num, st := s.num, s.st
14+ if st.TimeoutMinutes != nil { // step timeouts are phase G's
15+ j.errorf(num, "not supported yet: timeout-minutes")
16+ return resFailed
17+ }
1518 if st.Uses != nil {
1619 return j.runAction(s, stepCancel)
1720 }
@@ -6,12 +6,15 @@ import (
6 "fmt"6 "fmt"
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9-
10 )9 )
11 10
12 // execute is ActionRunner.RunAsync plus ScriptHandler for one run step.11 // execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
13 func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {12 func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
14 num, st := s.num, s.st13 num, st := s.num, s.st
14+ if st.TimeoutMinutes != nil { // step timeouts are phase G's
15+ j.errorf(num, "not supported yet: timeout-minutes")
16+ return resFailed
17+ }
15 if st.Uses != nil {18 if st.Uses != nil {
16 return j.runAction(s, stepCancel)19 return j.runAction(s, stepCancel)
17 }20 }
M internal/executor/step.go +10 -0
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "strings"
99
10+ "bnema.dev/ermite-actions/action"
1011 "bnema.dev/ermite-actions/expr"
1112 "bnema.dev/ermite-actions/template"
1213 "bnema.dev/ermite-actions/workflow"
@@ -119,6 +120,15 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string {
119120 case st.Run != nil:
120121 tok, prefix = st.Run, "Run "
121122 case st.Uses != nil:
123+ // The name is built from the parsed reference (ActionRunner.cs:305-333):
124+ // the image of a docker:// step without its scheme, "Name[/Path]@Ref" for
125+ // a repository, the path for a local action.
126+ if u, err := action.ParseUses(st.Uses.Value); err == nil {
127+ if u.Kind == action.UsesDocker {
128+ return formatStepName("Run ", u.Image)
129+ }
130+ return formatStepName("Run ", refString(u))
131+ }
122132 tok, prefix = st.Uses, "Run "
123133 default:
124134 return ""
@@ -7,6 +7,7 @@ import (
7 "errors"7 "errors"
8 "strings"8 "strings"
9 9
10+ "bnema.dev/ermite-actions/action"
10 "bnema.dev/ermite-actions/expr"11 "bnema.dev/ermite-actions/expr"
11 "bnema.dev/ermite-actions/template"12 "bnema.dev/ermite-actions/template"
12 "bnema.dev/ermite-actions/workflow"13 "bnema.dev/ermite-actions/workflow"
@@ -119,6 +120,15 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string {
119 case st.Run != nil:120 case st.Run != nil:
120 tok, prefix = st.Run, "Run "121 tok, prefix = st.Run, "Run "
121 case st.Uses != nil:122 case st.Uses != nil:
123+ // The name is built from the parsed reference (ActionRunner.cs:305-333):
124+ // the image of a docker:// step without its scheme, "Name[/Path]@Ref" for
125+ // a repository, the path for a local action.
126+ if u, err := action.ParseUses(st.Uses.Value); err == nil {
127+ if u.Kind == action.UsesDocker {
128+ return formatStepName("Run ", u.Image)
129+ }
130+ return formatStepName("Run ", refString(u))
131+ }
122 tok, prefix = st.Uses, "Run "132 tok, prefix = st.Uses, "Run "
123 default:133 default:
124 return ""134 return ""

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