v0.6.0 aba298d

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

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