test(executor): cover local, repository, composite, node and docker actions
- commit
- 622eabbd39d2b28ee318f589715f85075922aa01
- author
- brice · · 2026-10-13 16:35 UTC
- parent
- aea5d73
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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, | |||
| 237 | 237 | j.out(num, "##[command]"+cmd.line) | |
| 238 | 238 | // The docker process gets the container variables on top of its own | |
| 239 | 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 | 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 | 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 | + | } | |
| @@ -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 own | 238 | // 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strings" | |
| 9 | - | ||
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | // execute is ActionRunner.RunAsync plus ScriptHandler for one run step. | |
| 13 | 12 | func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |
| 14 | 13 | 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 | 18 | if st.Uses != nil { | |
| 16 | 19 | return j.runAction(s, stepCancel) | |
| 17 | 20 | } | |
| @@ -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.st | 13 | 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 ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | + | "bnema.dev/ermite-actions/action" | |
| 10 | 11 | "bnema.dev/ermite-actions/expr" | |
| 11 | 12 | "bnema.dev/ermite-actions/template" | |
| 12 | 13 | "bnema.dev/ermite-actions/workflow" | |
| @@ -119,6 +120,15 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string { | |||
| 119 | 120 | case st.Run != nil: | |
| 120 | 121 | tok, prefix = st.Run, "Run " | |
| 121 | 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 | 132 | tok, prefix = st.Uses, "Run " | |
| 123 | 133 | default: | |
| 124 | 134 | 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 "" | ||