v0.3.0 8f98fd3

actions_docker_test.go RawHistoryFolder

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}

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