1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "os"
7 "path/filepath"
8 "regexp"
9 "strings"
10 "testing"
11 "time"
12)
13
14// fakeDocker is a docker CLI that appends its arguments, one per line between
15// "--" markers, and the values of the variables it was told to look at, to a
16// file. pull and build succeed, "run" prints what it was given.
17func fakeDocker(t *testing.T, failPulls int) (path, logFile string) {
18 t.Helper()
19 dir := t.TempDir()
20 logFile = filepath.Join(dir, "calls")
21 counter := filepath.Join(dir, "pulls")
22 script := `#!/bin/sh
23{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; } >> ` + logFile + `
24case "$1" in
25pull)
26 n=$(cat ` + counter + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + counter + `
27 if [ "$n" -le ` + string(rune('0'+failPulls)) + ` ]; then echo "pull failed"; exit 1; fi
28 echo "pulled $2" ;;
29build) echo "built" ;;
30run)
31 echo "RUN-ENV INPUT_WHO=$INPUT_WHO HOME=$HOME GITHUB_ENV=$GITHUB_ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE FOO=$FOO"
32 echo "::set-output name=x::y" ;;
33esac
34exit 0
35`
36 if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
37 t.Fatal(err)
38 }
39 return filepath.Join(dir, "docker"), logFile
40}
41
42func calls(t *testing.T, logFile string) [][]string {
43 t.Helper()
44 b, _ := os.ReadFile(logFile)
45 var out [][]string
46 for _, blk := range strings.Split(string(b), "--\n") {
47 if blk = strings.TrimRight(blk, "\n"); blk != "" {
48 out = append(out, strings.Split(blk, "\n"))
49 }
50 }
51 return out
52}
53
54func dockerRun(t *testing.T, tree files, yaml string, failPulls int) (*recorder, Result, [][]string, *Layout) {
55 t.Helper()
56 d, lf := fakeDocker(t, failPulls)
57 var lay *Layout
58 rec, res := actionRun(t, tree, yaml, func(c *Config) {
59 c.Actions.DockerPath = d
60 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
61 lay = c.Layout
62 })
63 return rec, res, calls(t, lf), lay
64}
65
66func TestDockerHubImageAction(t *testing.T) {
67 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)
68 if res.Conclusion != ConclusionSuccess {
69 t.Fatalf("%+v %v", res, rec.names())
70 }
71 want := []string{"Set up job", "Pull alpine:3.20", "Run alpine:3.20", "Complete job"}
72 if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
73 t.Fatalf("steps %v", got)
74 }
75 pull := logOf(rec.steps[1])
76 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]") {
77 t.Errorf("pull:\n%s", pull)
78 }
79 run := logOf(rec.steps[2])
80 if !strings.Contains(run, "##[group]Run docker://alpine:3.20") || !strings.Contains(run, " entryPoint: /bin/sh") {
81 t.Errorf("run:\n%s", run)
82 }
83 if len(cs) != 2 {
84 t.Fatalf("calls %v", cs)
85 }
86 r := cs[1]
87 has := func(a ...string) bool {
88 for i := 0; i+len(a) <= len(r); i++ {
89 if strings.Join(r[i:i+len(a)], "\x00") == strings.Join(a, "\x00") {
90 return true
91 }
92 }
93 return false
94 }
95 ws, tmp := filepath.Join(lay.Workspace), lay.Temp
96 for _, w := range [][]string{
97 {"--workdir", "/github/workspace"}, {"--rm"}, {"-e", "INPUT_ENTRYPOINT"}, {"-e", "FOO"}, {"-e", "GITHUB_ACTIONS=true"}, {"-e", "CI=true"},
98 {"--entrypoint", "/bin/sh"}, {"-v", "/var/run/docker.sock:/var/run/docker.sock"}, {"-v", tmp + ":/github/runner_temp"},
99 {"-v", tmp + "/_github_home:/github/home"}, {"-v", tmp + "/_github_workflow:/github/workflow"},
100 {"-v", tmp + "/_runner_file_commands:/github/file_commands"}, {"-v", ws + ":/github/workspace"},
101 {"alpine:3.20", "-c", "echo hi there"},
102 } {
103 if !has(w...) {
104 t.Errorf("run lacks %q:\n%s", w, strings.Join(r, "\n"))
105 }
106 }
107 // The docker CLI gets the variables as the container sees them.
108 if !strings.Contains(run, "FOO=bar") || !strings.Contains(run, "HOME=/github/home") || !strings.Contains(run, "GITHUB_ENV=/github/file_commands/set_env_") ||
109 !strings.Contains(run, "GITHUB_WORKSPACE=/github/workspace ") {
110 t.Errorf("env of the docker process:\n%s", run)
111 }
112 // The echoed command line has the runner's quoting.
113 if !strings.Contains(run, `--entrypoint "/bin/sh"`) || !strings.Contains(run, `-e "FOO"`) || !strings.Contains(run, ` alpine:3.20 -c "echo hi there"`) {
114 t.Errorf("command line:\n%s", run)
115 }
116 if !strings.Contains(run, "-v \""+tmp+"\":\"/github/runner_temp\"") {
117 t.Errorf("volume quoting:\n%s", run)
118 }
119 // Inside the container the file command paths are the mounted ones; here the
120 // fake docker saw the host's, and what "-e" passes is translated.
121 if !strings.Contains(strings.Join(r, "\n"), "-e\nGITHUB_ENV\n") {
122 t.Errorf("GITHUB_ENV is passed by name:\n%s", strings.Join(r, "\n"))
123 }
124}
125
126func TestDockerPullRetries(t *testing.T) {
127 rec, res, cs, _ := dockerRun(t, files{}, " - uses: docker://busybox\n", 2)
128 if res.Conclusion != ConclusionSuccess || len(cs) != 4 {
129 t.Fatalf("%+v calls %d", res, len(cs))
130 }
131 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 {
132 t.Errorf("%s", got)
133 }
134 rec, res, _, _ = dockerRun(t, files{}, " - uses: docker://busybox\n", 5)
135 if res.Conclusion != ConclusionFailure || rec.steps[1].Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[1]), "##[error]Docker pull failed with exit code 1") {
136 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
137 }
138 if len(rec.steps) != 4 || rec.steps[2].Conclusion != ConclusionSkipped {
139 t.Errorf("a failed pull skips the step: %v", rec.names())
140 }
141}
142
143func TestDockerfileAction(t *testing.T) {
144 tree := files{
145 "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",
146 "act/Dockerfile": "FROM alpine\n",
147 }
148 rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n with:\n who: Ermite\n", 0)
149 if res.Conclusion != ConclusionSuccess {
150 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
151 }
152 got := logOf(rec.step(t, "Run ./act"))
153 // A local Dockerfile action builds in its own step (ContainerActionHandler.cs:64-84).
154 if !strings.Contains(got, "##[group]Building docker image\nDockerfile for action: '") || !strings.Contains(got, "##[command]") {
155 t.Errorf("%s", got)
156 }
157 if len(cs) != 2 || cs[0][0] != "build" || cs[1][0] != "run" {
158 t.Fatalf("calls %v", cs)
159 }
160 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") {
161 t.Errorf("build args %v", cs[0])
162 }
163 run := strings.Join(cs[1], "\n")
164 if !strings.Contains(run, "--entrypoint\n/entry.sh\n") || !strings.HasSuffix(run, "\nhello\nEr"+"mite\n\na \"quoted\" one") {
165 t.Errorf("args:\n%s", run)
166 }
167 if !strings.Contains(got, ` "hello" "Ermite" "" "a \"quoted\" one"`) {
168 t.Errorf("echoed args:\n%s", got)
169 }
170 if !strings.Contains(run, "-e\nACT_ENV\n") {
171 t.Errorf("env of the manifest:\n%s", run)
172 }
173}
174
175func TestRepositoryDockerfileActionBuildsInSetup(t *testing.T) {
176 forge := newForge(t, map[string][]byte{"/tarball/o/d/v1": tarOf(t, "o-d-x", files{"Dockerfile": "FROM alpine\n"})})
177 d, lf := fakeDocker(t, 0)
178 rec, res := actionRun(t, files{}, " - uses: o/d@v1\n - uses: o/d@v1\n", func(c *Config) {
179 c.Actions.DockerPath = d
180 c.Actions.Resolver = forge.resolver("")
181 })
182 if res.Conclusion != ConclusionSuccess {
183 t.Fatalf("%+v %v", res, rec.names())
184 }
185 want := "Set up job|Build o/d@v1|Run o/d@v1|Run o/d@v1|Complete job"
186 if got := strings.Join(rec.names(), "|"); got != want {
187 t.Fatalf("%s", got)
188 }
189 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]") {
190 t.Errorf("build step:\n%s", got)
191 }
192 if n := len(calls(t, lf)); n != 3 {
193 t.Errorf("one build for the two steps, then two runs: %d calls", n)
194 }
195 // Both runs use the image the setup step built.
196 cs := calls(t, lf)
197 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] {
198 t.Errorf("%v %v", cs[1], cs[2])
199 }
200}
201
202func TestEscapeDockerArg(t *testing.T) {
203 for in, want := range map[string]string{
204 "": "", "A": `"A"`, `a"b`: `"a\"b"`, `a\"b`: `"a\\\"b"`, `a\`: `"a\\"`, `a\\`: `"a\\\\"`, `a b`: `"a b"`,
205 } {
206 if got := escapeDockerArg(in); got != want {
207 t.Errorf("%q: %q, want %q", in, got, want)
208 }
209 }
210}
211
212func TestSplitArgs(t *testing.T) {
213 for in, want := range map[string]string{
214 ``: ``, `a b c`: `a|b|c`, `-c "echo hi"`: `-c|echo hi`, `a\"b`: `a"b`, `"" x`: `|x`, `a"b c"d`: `ab cd`,
215 } {
216 if got := strings.Join(splitArgs(in), "|"); got != want {
217 t.Errorf("%q: %q, want %q", in, got, want)
218 }
219 }
220}
221
222func TestTranslatePath(t *testing.T) {
223 maps := []pathMap{{"/w/ws", "/github/workspace"}, {"/w/tmp/_github_home", "/github/home"}, {"/w/tmp", "/github/runner_temp"}}
224 for in, want := range map[string]string{
225 "/w/ws": "/github/workspace", "/w/ws/a/b": "/github/workspace/a/b", "/w/tmp/_github_home/x": "/github/home/x",
226 "/w/tmp/f": "/github/runner_temp/f", "/w/wsx": "/w/wsx", "x": "x", "": "",
227 } {
228 if got := translatePath(maps, in); got != want {
229 t.Errorf("%q: %q, want %q", in, got, want)
230 }
231 }
232}
233
234// The Dockerfile is passed as written (Path.Combine) and the build context is
235// Directory.GetParent(dockerFile).FullName, which Path.GetFullPath resolves
236// lexically: "." and ".." are folded, symlinks are not looked at. Recorded in
237// act-docker-pre-post: -f ".../work/r/r/./.github/actions/x/Dockerfile" with the
238// context ".../work/r/r/.github/actions/x".
239func TestDockerfileContextIsTheLexicalParent(t *testing.T) {
240 tree := files{
241 "act/action.yml": "name: d\nruns:\n using: docker\n image: sub/../Dockerfile\n",
242 "act/Dockerfile": "FROM alpine\n",
243 "act/sub/keep": "",
244 }
245 rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n", 0)
246 if res.Conclusion != ConclusionSuccess {
247 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
248 }
249 if len(cs) < 1 || cs[0][0] != "build" {
250 t.Fatalf("calls %v", cs)
251 }
252 if f, c := cs[0][4], cs[0][5]; !strings.HasSuffix(f, "/./act/sub/../Dockerfile") || !strings.HasSuffix(c, "/act") || strings.Contains(c, "..") || strings.Contains(c, "/./") {
253 t.Errorf("-f %q, context %q", f, c)
254 }
255}
256
257// act-docker-image, act-docker-dockerfile: "Pull <image>" and "Build <action>"
258// are runner steps of the job and are in the steps context under a random
259// 32-hex id with their outcome and conclusion (JobExtension.cs:434-440,
260// ExecutionContext.cs:644).
261func TestSetupStepsAreInTheStepsContext(t *testing.T) {
262 needTools(t, "bash")
263 forge := newForge(t, map[string][]byte{"/tarball/o/d/v1": tarOf(t, "o-d-x", files{"Dockerfile": "FROM alpine\n"})})
264 d, _ := fakeDocker(t, 0)
265 rec, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n - uses: o/d@v1\n - name: dump\n run: echo \"S=$S\"\n env:\n S: ${{ join(steps.*.conclusion, ',') }} ${{ join(steps.*.outcome, ',') }} ${{ toJSON(steps) }}\n", func(c *Config) {
266 c.Actions.DockerPath = d
267 c.Actions.Resolver = forge.resolver("")
268 })
269 if res.Conclusion != ConclusionSuccess {
270 t.Fatalf("%+v %v", res, rec.names())
271 }
272 got := logOf(rec.step(t, "dump"))
273 re := regexp.MustCompile(`(?s)S=success,success success,success \{.*"[0-9a-f]{32}": \{\s*"outputs": \{\},\s*"outcome": "success",\s*"conclusion": "success"\s*\}.*"[0-9a-f]{32}": \{`)
274 if !re.MatchString(got) {
275 t.Errorf("%s", got)
276 }
277}
278
279// A setup step that is skipped (the pull before it failed) is in the steps
280// context too, with outcome and conclusion "skipped": the context names are
281// assigned before anything runs and CompleteStep(Skipped) updates the context
282// (JobExtension.cs:434-440, StepsRunner.cs:231-234, ExecutionContext.cs:644-647).
283func TestSkippedSetupStepIsInTheStepsContext(t *testing.T) {
284 needTools(t, "bash")
285 d, _ := fakeDocker(t, 3) // the three pulls of the first image fail
286 rec, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n - uses: docker://busybox:1\n - name: dump\n if: always()\n run: echo \"S=$S\"\n env:\n S: ${{ toJSON(steps) }}\n", func(c *Config) {
287 c.Actions.DockerPath = d
288 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
289 })
290 if res.Conclusion != ConclusionFailure {
291 t.Fatalf("%+v %v", res, rec.names())
292 }
293 if got := strings.Join(rec.names(), "|"); got != "Set up job|Pull alpine:3.20|Pull busybox:1|Run alpine:3.20|Run busybox:1|dump|Complete job" {
294 t.Fatalf("%s", got)
295 }
296 got := logOf(rec.step(t, "dump"))
297 got = got[strings.LastIndex(got, "\nS={"):] // the header echoes the env too
298 for _, want := range []string{"failure", "skipped"} {
299 re := regexp.MustCompile(`(?s)"[0-9a-f]{32}": \{\s*"outputs": \{\},\s*"outcome": "` + want + `",\s*"conclusion": "` + want + `"\s*\}`)
300 if !re.MatchString(got) {
301 t.Errorf("no %s runner step in:\n%s", want, got)
302 }
303 }
304 if n := len(regexp.MustCompile(`"[0-9a-f]{32}": \{`).FindAllString(got, -1)); n != 2 {
305 t.Errorf("%d runner steps in the context, want 2:\n%s", n, got)
306 }
307}