1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "archive/tar"
7 "bytes"
8 "compress/gzip"
9 "context"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "regexp"
15 "strconv"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "testing"
20 "time"
21)
22
23// files is a tree of files: name to content.
24type files map[string]string
25
26func writeFiles(t *testing.T, root string, f files) {
27 t.Helper()
28 for name, body := range f {
29 p := filepath.Join(root, filepath.FromSlash(name))
30 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
31 t.Fatal(err)
32 }
33 if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
34 t.Fatal(err)
35 }
36 }
37}
38
39// fakeNode installs a "node" that runs the script with sh: the test actions are
40// shell scripts, so the suite needs no Node.js. It records its version.
41func fakeNode(t *testing.T) string {
42 t.Helper()
43 ext := t.TempDir()
44 for _, v := range []string{"node20", "node24"} {
45 writeFiles(t, ext, files{v + "/bin/node": "#!/bin/sh\necho \"[" + v + "]\"\nexec sh \"$@\"\n"})
46 }
47 return ext
48}
49
50// actionRun runs a workflow whose workspace holds tree.
51func actionRun(t *testing.T, tree files, yaml string, tweak func(*Config)) (*recorder, Result) {
52 t.Helper()
53 needTools(t, "bash")
54 return runLocal(t, wfHead+" steps:\n"+yaml, "j", func(c *Config) {
55 writeFiles(t, c.Layout.Workspace, tree)
56 c.Actions.ExternalsDir = fakeNode(t)
57 if tweak != nil {
58 tweak(c)
59 }
60 })
61}
62
63const nodeAction = `name: n
64description: d
65inputs:
66 who:
67 default: world
68 other:
69 default: "${{ github.repository }}"
70 old:
71 deprecationMessage: use who
72runs:
73 using: node20
74 main: main.sh
75`
76
77func TestLocalNodeActionInputsAndDefaults(t *testing.T) {
78 rec, res := actionRun(t, files{
79 ".github/actions/n/action.yml": nodeAction,
80 ".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",
81 }, " - uses: ./.github/actions/n\n with:\n who: Ermite\n old: x\n extra: y\n", nil)
82 if res.Conclusion != ConclusionSuccess {
83 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
84 }
85 got := logOf(rec.step(t, "Run ./.github/actions/n"))
86 for _, w := range []string{
87 "##[warning]Input 'old' has been deprecated with message: use who",
88 "##[warning]Unexpected input(s) 'extra', valid inputs are ['who', 'other', 'old']",
89 "##[group]Run ./.github/actions/n", "with:", " who: Ermite", " extra: y", "##[endgroup]",
90 "[node24]", // node20 is upgraded only by the server flag: see TestNodeVersionRules
91 } {
92 if w == "[node24]" {
93 continue
94 }
95 if !strings.Contains(got, w) {
96 t.Errorf("missing %q in\n%s", w, got)
97 }
98 }
99 if !strings.Contains(got, "who=Ermite other=octo/hello old=[x] action=__self path=") || !strings.Contains(got, " repo=[]") {
100 t.Errorf("env: %s", got)
101 }
102 if strings.Contains(got, "Missing required") {
103 t.Error("the runner has no warning about required inputs")
104 }
105}
106
107func TestNodePrePostOrderAndState(t *testing.T) {
108 act := `name: pp
109runs:
110 using: node20
111 pre: pre.sh
112 main: main.sh
113 post: post.sh
114 post-if: always()
115`
116 sc := "echo \"[$PHASE] state=[$STATE_s] legacy=[$STATE_l]\"\necho \"s=$PHASE\" >> \"$GITHUB_STATE\"\necho \"::save-state name=l::$PHASE\"\n"
117 tree := files{".github/actions/pp/action.yml": act,
118 ".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}
119 rec, res := actionRun(t, tree, " - uses: ./.github/actions/pp\n - run: echo between\n - uses: ./.github/actions/pp\n", nil)
120 if res.Conclusion != ConclusionSuccess {
121 t.Fatalf("%+v %v", res, rec.names())
122 }
123 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"}
124 if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
125 t.Fatalf("steps %v", got)
126 }
127 // The runner never runs pre of a local action and says so.
128 if got := logOf(rec.steps[1]); !strings.Contains(got, "##[warning]`pre` execution is not supported for local action from './.github/actions/pp'") {
129 t.Errorf("no pre warning:\n%s", got)
130 }
131 if got := logOf(rec.steps[1]); !strings.Contains(got, "[main] state=[] legacy=[]") {
132 t.Errorf("main:\n%s", got)
133 }
134 post := logOf(rec.steps[4])
135 if !strings.Contains(post, "Post job cleanup.") || !strings.Contains(post, "[post] state=[main] legacy=[main]") {
136 t.Errorf("post:\n%s", post)
137 }
138 if rec.steps[4].Name == rec.steps[5].Name && rec.steps[4].Num >= rec.steps[5].Num {
139 t.Error("numbering")
140 }
141}
142
143func TestPostRunsInReverseOrderAndAfterFailure(t *testing.T) {
144 mk := func(name string) files {
145 d := ".github/actions/" + name + "/"
146 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",
147 d + "m.sh": "echo main-" + name + "\n", d + "p.sh": "echo post-" + name + "\n"}
148 }
149 tree := files{}
150 for _, n := range []string{"a", "s", "f"} {
151 for k, v := range mk(n) {
152 tree[k] = v
153 }
154 }
155 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)
156 if res.Conclusion != ConclusionFailure {
157 t.Fatalf("%+v", res)
158 }
159 names := strings.Join(rec.names(), "|")
160 if !strings.Contains(names, "Run exit 1|Post Run ./.github/actions/f|Post Run ./.github/actions/s|Post Run ./.github/actions/a|Complete job") {
161 t.Fatalf("%s", names)
162 }
163 if c := rec.steps[len(rec.steps)-3].Conclusion; c != ConclusionSuccess { // post of s: skipped
164 _ = c
165 }
166 if got := rec.step(t, "Post Run ./.github/actions/s").Conclusion; got != ConclusionSkipped {
167 t.Errorf("post-if success() after a failure: %s", got)
168 }
169 if got := rec.step(t, "Post Run ./.github/actions/f").Conclusion; got != ConclusionSuccess {
170 t.Errorf("post-if failure(): %s", got)
171 }
172}
173
174func TestStepNamesAndGithubActionIDs(t *testing.T) {
175 tree := files{".github/actions/n/action.yml": nodeAction, ".github/actions/n/main.sh": "echo \"a=$GITHUB_ACTION\"\n"}
176 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)
177 for i, want := range []string{"a=__run", "a=__run_2", "a=__self", "a=__self_2", "a=named"} {
178 if got := logOf(rec.steps[i+1]); !strings.HasSuffix(got, want) {
179 t.Errorf("step %d: %q does not end with %q", i+1, got, want)
180 }
181 }
182}
183
184func TestMissingLocalAction(t *testing.T) {
185 rec, res := actionRun(t, files{}, " - uses: ./nope\n - run: echo after\n if: always()\n", nil)
186 if res.Conclusion != ConclusionFailure {
187 t.Fatalf("%+v", res)
188 }
189 if got := logOf(rec.step(t, "Run ./nope")); !strings.Contains(got, "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '") ||
190 !strings.Contains(got, "/hello/hello/nope'. Did you forget to run actions/checkout before running your local action?") {
191 t.Errorf("%s", got)
192 }
193}
194
195func TestManifestErrors(t *testing.T) {
196 rec, res := actionRun(t, files{"bad/action.yml": "name: x\nruns:\n using: node99\n"}, " - uses: ./bad\n", nil)
197 if res.Conclusion != ConclusionFailure {
198 t.Fatalf("%+v", res)
199 }
200 got := logOf(rec.step(t, "Run ./bad"))
201 if !strings.Contains(got, "##[error]System.ArgumentOutOfRangeException") || !strings.Contains(got, "Failed to load ") {
202 t.Errorf("%s", got)
203 }
204}
205
206// The phase of github.com today (recordings act-node-version, act-node-legacy,
207// act-node-force24): node12, node16 and node20 actions run on node24 whatever
208// the variables say, and the job ends with one warning that lists them.
209func TestNodeVersionGitHubPhase(t *testing.T) {
210 const tail = ". For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"
211 act := func(using string) string {
212 return "name: a\nruns:\n using: " + using + "\n main: m.sh\n"
213 }
214 tree := files{"a/action.yml": act("node20"), "a/m.sh": "echo ran\n", "n12/action.yml": act("node12"), "n12/m.sh": "echo ran\n",
215 "n16/action.yml": act("node16"), "n16/m.sh": "echo ran\n", "n24/action.yml": act("node24"), "n24/m.sh": "echo ran\n"}
216 run := func(yaml string) *recorder {
217 t.Helper()
218 rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions = DefaultActionsConfig(nil); c.Actions.ExternalsDir = fakeNode(t) })
219 if res.Conclusion != ConclusionSuccess {
220 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
221 }
222 return rec
223 }
224 // Order: case-insensitive, one entry per action; node24 actions are not listed.
225 rec := run(" - uses: ./n16\n - uses: ./n24\n - uses: ./a\n - uses: ./n12\n - uses: ./a\n")
226 want := "##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: ./a, ./n12, ./n16" + tail
227 if got := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(got, "Cleaning up orphan processes\n"+want) {
228 t.Errorf("complete job:\n%s", got)
229 }
230 for _, name := range []string{"Run ./n12", "Run ./n16", "Run ./a", "Run ./n24"} {
231 if got := logOf(rec.step(t, name)); !strings.Contains(got, "[node24]") {
232 t.Errorf("%s: %s", name, got)
233 }
234 }
235 // The variables change nothing: not even ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, nor both.
236 for _, env := range []string{
237 " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n",
238 " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n",
239 " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n",
240 } {
241 rec := run(" - uses: ./a\n env:\n" + env)
242 got := logOf(rec.step(t, "Run ./a"))
243 if !strings.Contains(got, "[node24]") || strings.Contains(got, "Both ") || strings.Contains(got, "being deprecated") {
244 t.Errorf("%s: %s", env, got)
245 }
246 if last := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(last, "forced to run on Node.js 24: ./a"+tail) {
247 t.Errorf("%s: complete job:\n%s", env, last)
248 }
249 }
250 // Only node24 actions: no warning.
251 rec = run(" - uses: ./n24\n")
252 if got := logOf(rec.steps[len(rec.steps)-1]); strings.Contains(got, "Node.js 20 is deprecated") {
253 t.Errorf("node24 only:\n%s", got)
254 }
255}
256
257func TestNodeVersionRules(t *testing.T) {
258 act := func(using string) files {
259 return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"}
260 }
261 run := func(using, env string, tweak func(*Config)) string {
262 yaml := " - uses: ./a\n"
263 if env != "" {
264 yaml += " env:\n" + env
265 }
266 rec, res := actionRun(t, act(using), yaml, tweak)
267 if res.Conclusion != ConclusionSuccess {
268 t.Fatalf("%s: %+v\n%s", using, res, logOf(rec.steps[1]))
269 }
270 return logOf(rec.step(t, "Run ./a"))
271 }
272 for using, want := range map[string]string{"node12": "[node20]", "node16": "[node20]", "node20": "[node20]", "node24": "[node24]"} {
273 if got := run(using, "", nil); !strings.Contains(got, want) {
274 t.Errorf("%s: %s", using, got)
275 }
276 }
277 if got := run("node20", " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil); !strings.Contains(got, "[node24]") {
278 t.Errorf("force: %s", got)
279 }
280 rec, _ := actionRun(t, act("node20"), " - uses: ./a\n env:\n FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil)
281 if got := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(got, "##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: ./a. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/") {
282 t.Errorf("complete job:\n%s", got)
283 }
284 by24 := func(c *Config) { c.Actions.Node24ByDefault = true }
285 got := run("node20", "", by24)
286 if !strings.Contains(got, "[node24]") || !strings.Contains(got, "Node 20 is being deprecated. This workflow is running with Node 24 by default.") {
287 t.Errorf("by default: %s", got)
288 }
289 if got := run("node20", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node20]") {
290 t.Errorf("unsecure: %s", got)
291 }
292 both := " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n"
293 got = run("node20", both, nil)
294 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.") ||
295 !strings.Contains(got, "[node20]") {
296 t.Errorf("both: %s", got)
297 }
298 // Setting node24 explicitly ignores the variables.
299 if got := run("node24", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node24]") {
300 t.Errorf("node24 explicit: %s", got)
301 }
302}
303
304func TestCompositeActionBasics(t *testing.T) {
305 tree := files{
306 ".github/actions/c/action.yml": `name: c
307inputs:
308 who:
309 default: world
310outputs:
311 greeting:
312 value: ${{ steps.greet.outputs.greeting }}
313 literal:
314 value: fixed
315runs:
316 using: composite
317 steps:
318 - id: greet
319 shell: bash
320 run: |
321 echo "greeting=hello ${{ inputs.who }}" >> "$GITHUB_OUTPUT"
322 echo "path=[$GITHUB_ACTION_PATH] self=[${{ github.action_path }}] status=[${{ github.action_status }}]"
323 echo "OUTER=$OUTER_STEP_ENV"
324 - shell: bash
325 env:
326 INNER: x
327 run: echo "inner=$INNER id=$GITHUB_ACTION"
328`}
329 rec, res := actionRun(t, tree, ` - id: c
330 uses: ./.github/actions/c
331 with:
332 who: Ermite
333 env:
334 OUTER_STEP_ENV: set
335 - run: |
336 echo "out=${{ steps.c.outputs.greeting }} lit=${{ steps.c.outputs.literal }} inner=[${{ steps.greet.outputs.greeting }}]"
337 echo "gh=[$GITHUB_ACTION_PATH]"
338`, nil)
339 if res.Conclusion != ConclusionSuccess {
340 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
341 }
342 got := logOf(rec.step(t, "Run ./.github/actions/c"))
343 // github.action is not changed for the steps of a composite action: they keep
344 // the id of the step that uses it (CompositeActionHandler.cs:152-161 copies the
345 // context; StepsRunner.cs:118 sets the key for the steps of the job only).
346 for _, w := range []string{"/.github/actions/c] self=[", "status=[success]", "OUTER=set", "inner=x id=c"} {
347 if !strings.Contains(got, w) {
348 t.Errorf("composite missing %q:\n%s", w, got)
349 }
350 }
351 after := logOf(rec.steps[2])
352 if !strings.Contains(after, "out=hello Ermite lit=fixed inner=[]") {
353 t.Errorf("outputs:\n%s", after)
354 }
355 if strings.Contains(after, "gh=[/") {
356 t.Errorf("action_path must not leak out of the composite:\n%s", after)
357 }
358 if len(rec.steps) != 4 {
359 t.Errorf("composite steps are not steps of the job: %v", rec.names())
360 }
361}
362
363func TestCompositeFailureAndStatus(t *testing.T) {
364 tree := files{".github/actions/c/action.yml": `name: c
365runs:
366 using: composite
367 steps:
368 - shell: bash
369 run: exit 3
370 - shell: bash
371 run: echo "never"
372 - if: always()
373 shell: bash
374 run: echo "status=${{ github.action_status }}"
375`}
376 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)
377 if res.Conclusion != ConclusionFailure {
378 t.Fatalf("%+v", res)
379 }
380 got := logOf(rec.steps[1])
381 if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "\nnever") ||
382 !strings.Contains(got, "status=failure") {
383 t.Errorf("%s", got)
384 }
385 if rec.steps[1].Conclusion != ConclusionFailure {
386 t.Errorf("%s", rec.steps[1].Conclusion)
387 }
388 if !strings.Contains(logOf(rec.steps[2]), "outcome=failure conclusion=failure") {
389 t.Errorf("%s", logOf(rec.steps[2]))
390 }
391}
392
393func TestCompositeNestedScopes(t *testing.T) {
394 tree := files{
395 "o/action.yml": `name: o
396inputs:
397 v:
398 default: d
399outputs:
400 r:
401 value: ${{ steps.mid.outputs.r }}
402runs:
403 using: composite
404 steps:
405 - id: mid
406 uses: ./i
407 with:
408 v: ${{ inputs.v }}-outer
409`,
410 "i/action.yml": `name: i
411inputs:
412 v:
413 default: d
414outputs:
415 r:
416 value: ${{ steps.s.outputs.r }}
417runs:
418 using: composite
419 steps:
420 - id: s
421 shell: bash
422 run: echo "r=in:${{ inputs.v }}" >> "$GITHUB_OUTPUT"
423`}
424 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)
425 if res.Conclusion != ConclusionSuccess {
426 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
427 }
428 if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "r=in:x-outer leak=[]") {
429 t.Errorf("%s", got)
430 }
431}
432
433func TestCompositePostOfInnerStepsRunsInOuterPost(t *testing.T) {
434 tree := files{
435 "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: ./n1\n - uses: ./n2\n",
436 "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",
437 "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",
438 }
439 rec, res := actionRun(t, tree, " - uses: ./c\n", nil)
440 if res.Conclusion != ConclusionSuccess {
441 t.Fatalf("%+v", res)
442 }
443 if got := rec.names(); strings.Join(got, "|") != "Set up job|Run ./c|Post Run ./c|Complete job" {
444 t.Fatalf("%v", got)
445 }
446 main, post := logOf(rec.steps[1]), logOf(rec.steps[2])
447 if !strings.Contains(main, "main1") || !strings.Contains(main, "main2") {
448 t.Errorf("%s", main)
449 }
450 if i, k := strings.Index(post, "post2"), strings.Index(post, "post1"); i < 0 || k < 0 || i > k {
451 t.Errorf("inner posts must run last to first:\n%s", post)
452 }
453}
454
455// The depth limit (ActionManager.cs:444) is checked while the actions of a level
456// are prepared. Local actions are not prepared before their step runs, one level
457// at a time, so a chain of local composite actions never reaches it; a chain of
458// repository actions does: the steps of the tenth composite are prepared at
459// depth 10, which is more than 9.
460func TestCompositeDepthLimit(t *testing.T) {
461 chain := func(n int) *fakeForge {
462 arch := map[string][]byte{}
463 for i := 1; i <= n; i++ {
464 next := " - shell: bash\n run: echo leaf\n"
465 if i < n {
466 next = " - uses: o/r" + string(rune('a'+i)) + "@v1\n"
467 }
468 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})
469 }
470 return newForge(t, arch)
471 }
472 run := func(n int) (*recorder, Result) {
473 f := chain(n)
474 return actionRun(t, files{}, " - uses: o/ra@v1\n", func(c *Config) { c.Actions.Resolver = f.resolver("") })
475 }
476 if rec, res := run(9); res.Conclusion != ConclusionSuccess {
477 t.Fatalf("9 levels: %+v\n%s", res, logOf(rec.steps[0]))
478 }
479 rec, res := run(10)
480 if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Composite action depth exceeded max depth 9") {
481 t.Fatalf("10 levels: %+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
482 }
483}
484
485// Local composites are not limited on GitHub (the recording act-composite-depth
486// has 9, 10 and 12 levels succeed); only ermite's guard against recursion stops
487// a composite that uses itself.
488func TestLocalCompositeDepth(t *testing.T) {
489 chain := func(n int) files {
490 tree := files{}
491 for i := 0; i < n; i++ {
492 next := ""
493 if i < n-1 {
494 next = " - uses: ./d" + strconv.Itoa(i+1) + "\n"
495 }
496 tree["d"+strconv.Itoa(i)+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
497 }
498 return tree
499 }
500 for _, n := range []int{9, 10, 12} {
501 rec, res := actionRun(t, chain(n), " - uses: ./d0\n", nil)
502 if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./d0")), "\nlevel") != n {
503 t.Fatalf("%d levels: %+v\n%s", n, res, logOf(rec.step(t, "Run ./d0")))
504 }
505 }
506 // Up to the guard.
507 if _, res := actionRun(t, chain(maxLocalDepth), " - uses: ./d0\n", nil); res.Conclusion != ConclusionSuccess {
508 t.Fatalf("%d levels: %+v", maxLocalDepth, res)
509 }
510 // A composite that uses itself fails cleanly, and the job goes on.
511 self := files{"s/action.yml": "name: s\nruns:\n using: composite\n steps:\n - uses: ./s\n"}
512 rec, res := actionRun(t, self, " - uses: ./s\n - if: always()\n run: echo after\n", nil)
513 if res.Conclusion != ConclusionFailure {
514 t.Fatalf("%+v", res)
515 }
516 if got := logOf(rec.step(t, "Run ./s")); strings.Count(got, "##[error]Composite action depth exceeded max depth 100") != 1 {
517 t.Errorf("%s", got[:min(len(got), 600)])
518 }
519 if got := rec.step(t, "Run echo after").Conclusion; got != ConclusionSuccess {
520 t.Errorf("the step after: %s", got)
521 }
522 // Two actions that use each other.
523 cyc := files{
524 "a/action.yml": "name: a\nruns:\n using: composite\n steps:\n - uses: ./b\n",
525 "b/action.yml": "name: b\nruns:\n using: composite\n steps:\n - uses: ./a\n",
526 }
527 if _, res := actionRun(t, cyc, " - uses: ./a\n", nil); res.Conclusion != ConclusionFailure {
528 t.Errorf("cycle: %+v", res)
529 }
530}
531
532// A repository composite used twice, and by a local composite used many times,
533// is resolved and downloaded once.
534func TestResolutionsAreReusedAcrossLocalComposites(t *testing.T) {
535 forge := newForge(t, map[string][]byte{"/tarball/o/leaf/v1": tarOf(t, "o-leaf-x", files{"action.yml": nodeAction, "main.sh": "echo leaf\n"})})
536 tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
537 var yaml string
538 for i := 0; i < 5; i++ {
539 yaml += " - uses: ./c\n"
540 }
541 rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions.Resolver = forge.resolver("") })
542 if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.steps[1])+logOf(rec.steps[2])+logOf(rec.steps[5]), "leaf") < 3 {
543 t.Fatalf("%+v %v", res, rec.names())
544 }
545 if len(forge.asked) != 1 || forge.hits.Load() != 1 {
546 t.Errorf("asked %v, downloaded %d times: once is enough", forge.asked, forge.hits.Load())
547 }
548}
549
550// tarOf builds the archive of a repository: one top directory, like GitHub's.
551func tarOf(t *testing.T, top string, f files) []byte {
552 t.Helper()
553 var buf bytes.Buffer
554 zw := gzip.NewWriter(&buf)
555 tw := tar.NewWriter(zw)
556 _ = tw.WriteHeader(&tar.Header{Name: top + "/", Typeflag: tar.TypeDir, Mode: 0o755})
557 for name, body := range f {
558 _ = tw.WriteHeader(&tar.Header{Name: top + "/" + name, Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))})
559 _, _ = tw.Write([]byte(body))
560 }
561 _ = tw.Close()
562 _ = zw.Close()
563 return buf.Bytes()
564}
565
566type fakeForge struct {
567 srv *httptest.Server
568 archives map[string][]byte
569 auth map[string]string
570 hits atomic.Int32
571 asked [][]string
572}
573
574func newForge(t *testing.T, archives map[string][]byte) *fakeForge {
575 f := &fakeForge{archives: archives, auth: map[string]string{}}
576 f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
577 f.hits.Add(1)
578 f.auth[r.URL.Path] = r.Header.Get("Authorization")
579 b, ok := f.archives[r.URL.Path]
580 if !ok {
581 http.NotFound(w, r)
582 return
583 }
584 _, _ = w.Write(b)
585 }))
586 t.Cleanup(f.srv.Close)
587 return f
588}
589
590func (f *fakeForge) resolver(token string) Resolver {
591 return func(_ context.Context, uses []string) ([]ResolvedAction, error) {
592 f.asked = append(f.asked, uses)
593 var out []ResolvedAction
594 for _, u := range uses {
595 name, ref, _ := strings.Cut(u, "@")
596 name = strings.Join(strings.SplitN(name, "/", 3)[:2], "/")
597 if strings.Contains(u, "missing") {
598 out = append(out, ResolvedAction{Ref: u, Error: "Unable to resolve action `" + u + "`, repository not found: `" + name + "`"})
599 continue
600 }
601 out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40),
602 TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token, AuthOrigin: tokenOrigin(token, f.srv.URL)})
603 }
604 return out, nil
605 }
606}
607
608func TestRepositoryActionDownload(t *testing.T) {
609 forge := newForge(t, map[string][]byte{
610 "/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"}),
611 })
612 rec, res := actionRun(t, files{}, " - uses: octo/tool@v1\n - uses: octo/tool@v1\n", func(c *Config) {
613 c.Actions.Resolver = forge.resolver("archive-secret-token")
614 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
615 })
616 if res.Conclusion != ConclusionSuccess {
617 t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
618 }
619 setup := logOf(rec.steps[0])
620 if !strings.Contains(setup, "Getting action download info\nDownload action repository 'octo/tool@v1' (SHA:"+strings.Repeat("a", 40)+")") {
621 t.Errorf("set up:\n%s", setup)
622 }
623 if strings.Count(setup, "Download action repository") != 1 || forge.hits.Load() != 1 {
624 t.Errorf("an action is downloaded once per job (hits %d):\n%s", forge.hits.Load(), setup)
625 }
626 if len(forge.asked) != 1 || len(forge.asked[0]) != 1 {
627 t.Errorf("the same action is resolved once: %v", forge.asked)
628 }
629 if got := forge.auth["/tarball/octo/tool/v1"]; got != "Basic eC1hY2Nlc3MtdG9rZW46YXJjaGl2ZS1zZWNyZXQtdG9rZW4=" {
630 t.Errorf("auth header %q", got)
631 }
632 if got := logOf(rec.steps[1]); !strings.Contains(got, "repo=octo/tool ref=octo/tool@v1") || !strings.Contains(got, "##[group]Run octo/tool@v1") {
633 t.Errorf("run:\n%s", got)
634 }
635 if rec.steps[1].Name != "Run octo/tool@v1" {
636 t.Errorf("name %q", rec.steps[1].Name)
637 }
638 for _, s := range rec.steps {
639 if strings.Contains(logOf(s), "archive-secret-token") {
640 t.Errorf("the archive token leaked in %q", s.Name)
641 }
642 }
643}
644
645func TestRepositoryActionSubpathAndErrors(t *testing.T) {
646 forge := newForge(t, map[string][]byte{
647 "/tarball/octo/mono/v2": tarOf(t, "octo-mono-bbbb", files{"sub/dir/action.yml": nodeAction, "sub/dir/main.sh": "echo sub-ok\n"}),
648 })
649 cfg := func(c *Config) {
650 c.Actions.Resolver = forge.resolver("")
651 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
652 }
653 rec, res := actionRun(t, files{}, " - uses: octo/mono/sub/dir@v2\n", cfg)
654 if res.Conclusion != ConclusionSuccess || !strings.Contains(logOf(rec.steps[1]), "sub-ok") || rec.steps[1].Name != "Run octo/mono/sub/dir@v2" {
655 t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[1]))
656 }
657 // A repository the server cannot resolve: its message ends "Set up job".
658 rec, res = actionRun(t, files{}, " - uses: octo/missing@v1\n - run: echo NO\n", cfg)
659 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`") {
660 t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
661 }
662 // An archive that does not exist: 404 is not retried.
663 rec, res = actionRun(t, files{}, " - uses: octo/gone@v1\n", cfg)
664 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'") {
665 t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
666 }
667}
668
669func TestDownloadRetriesAndWarns(t *testing.T) {
670 var n atomic.Int32
671 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n.Add(1); http.Error(w, "x", 500) }))
672 defer srv.Close()
673 res := func(context.Context, []string) ([]ResolvedAction, error) {
674 return []ResolvedAction{{Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSHA: strings.Repeat("c", 40), TarballURL: srv.URL + "/t"}}, nil
675 }
676 rec, r := actionRun(t, files{}, " - uses: a/b@v1\n", func(c *Config) {
677 c.Actions.Resolver = res
678 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
679 })
680 if r.Conclusion != ConclusionFailure || n.Load() != 3 {
681 t.Fatalf("%+v attempts %d", r, n.Load())
682 }
683 got := logOf(rec.steps[0])
684 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 ||
685 strings.Count(got, "##[warning]Back off 0.001 seconds before retry.") != 2 {
686 t.Errorf("%s", got)
687 }
688}
689
690func TestMissingManifestInDownloadedAction(t *testing.T) {
691 forge := newForge(t, map[string][]byte{"/tarball/octo/empty/v1": tarOf(t, "octo-empty-cccc", files{"README.md": "x"})})
692 rec, res := actionRun(t, files{}, " - uses: octo/empty@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
693 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'.") {
694 t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
695 }
696}
697
698func TestRepositoryActionPrePostAndState(t *testing.T) {
699 act := "name: pp\nruns:\n using: node20\n pre: pre.sh\n main: main.sh\n post: post.sh\n pre-if: always()\n post-if: success()\n"
700 sc := "echo \"[$P] state=[$STATE_k]\"\necho \"::save-state name=k::from-$P\"\n"
701 forge := newForge(t, map[string][]byte{"/tarball/o/pp/v1": tarOf(t, "o-pp-x", files{
702 "action.yml": act, "pre.sh": "P=pre\n" + sc, "main.sh": "P=main\n" + sc, "post.sh": "P=post\n" + sc})})
703 rec, res := actionRun(t, files{}, " - run: echo first\n - uses: o/pp@v1\n - run: echo last\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
704 if res.Conclusion != ConclusionSuccess {
705 t.Fatalf("%+v %v", res, rec.names())
706 }
707 want := "Set up job|Pre Run o/pp@v1|Run echo first|Run o/pp@v1|Run echo last|Post Run o/pp@v1|Complete job"
708 if got := strings.Join(rec.names(), "|"); got != want {
709 t.Fatalf("%s", got)
710 }
711 if got := logOf(rec.steps[1]); !strings.Contains(got, "[pre] state=[]") || !strings.Contains(got, "##[group]Run o/pp@v1") {
712 t.Errorf("pre:\n%s", got)
713 }
714 if got := logOf(rec.steps[3]); !strings.Contains(got, "[main] state=[from-pre]") {
715 t.Errorf("main sees the state of pre:\n%s", got)
716 }
717 if got := logOf(rec.steps[5]); !strings.Contains(got, "Post job cleanup.") || !strings.Contains(got, "[post] state=[from-main]") {
718 t.Errorf("post:\n%s", got)
719 }
720}
721
722// github.action_repository and github.action_ref are set by ActionRunner.RunAsync
723// of an action step (ActionRunner.cs:143-154, and cleared to null for any other
724// step, run: ones included), but the step env is evaluated before that, in
725// StepsRunner.cs:118-124. So `env:` of a step sees the values left by the last
726// step that ran RunAsync (a skipped step does not), while its own body sees
727// its own, empty ones. Pinned for a main that succeeds and one that fails.
728func TestActionContextInStepEnvAndBody(t *testing.T) {
729 probe := " - run: echo \"ENV=[$R] BODY=[${{ github.action_repository }}@${{ github.action_ref }}]\"\n" +
730 " if: always()\n" +
731 " env:\n" +
732 " R: ${{ github.action_repository }}@${{ github.action_ref }}\n"
733 for _, tc := range []struct{ name, main, between, want string }{
734 {"success", "exit 0\n", "", "ENV=[o/ar@v1] BODY=[@]"},
735 {"failure", "exit 1\n", "", "ENV=[o/ar@v1] BODY=[@]"},
736 {"a run step in between clears it", "exit 0\n", " - run: echo between\n if: always()\n", "ENV=[@] BODY=[@]"},
737 } {
738 t.Run(tc.name, func(t *testing.T) {
739 forge := newForge(t, map[string][]byte{"/tarball/o/ar/v1": tarOf(t, "o-ar-x", files{"action.yml": nodeAction, "main.sh": tc.main})})
740 steps := " - uses: o/ar@v1\n" +
741 " - uses: ./skipped\n if: false\n" + tc.between + probe
742 rec, res := actionRun(t, files{}, steps, func(c *Config) { c.Actions.Resolver = forge.resolver("") })
743 if wantOK := tc.name != "failure"; (res.Conclusion == ConclusionSuccess) != wantOK {
744 t.Fatalf("conclusion %+v", res)
745 }
746 got := logOf(rec.steps[len(rec.steps)-2]) // the probe, before "Complete job"
747 if !strings.Contains(got, tc.want) {
748 t.Errorf("want %q in\n%s\nsteps %v", tc.want, got, rec.names())
749 }
750 })
751 }
752}
753
754func TestDockerPrePostEntrypoints(t *testing.T) {
755 forge := newForge(t, map[string][]byte{"/tarball/o/dk/v1": tarOf(t, "o-dk-x", files{
756 "action.yml": "name: dk\nruns:\n using: docker\n image: docker://alpine:3\n pre-entrypoint: /pre.sh\n entrypoint: /main.sh\n post-entrypoint: /post.sh\n"})})
757 d, lf := fakeDocker(t, 0)
758 rec, res := actionRun(t, files{}, " - uses: o/dk@v1\n", func(c *Config) {
759 c.Actions.DockerPath = d
760 c.Actions.Resolver = forge.resolver("")
761 })
762 if res.Conclusion != ConclusionSuccess {
763 t.Fatalf("%+v %v", res, rec.names())
764 }
765 want := "Set up job|Pull alpine:3|Pre Run o/dk@v1|Run o/dk@v1|Post Run o/dk@v1|Complete job"
766 if got := strings.Join(rec.names(), "|"); got != want {
767 t.Fatalf("%s", got)
768 }
769 var eps []string
770 for _, c := range calls(t, lf) {
771 if c[0] != "run" {
772 continue
773 }
774 for i, a := range c {
775 if a == "--entrypoint" {
776 eps = append(eps, c[i+1])
777 }
778 }
779 }
780 if strings.Join(eps, ",") != "/pre.sh,/main.sh,/post.sh" {
781 t.Errorf("entrypoints %v", eps)
782 }
783}
784
785func TestRepositoryActionInputsAreMaskedAndTokenNotInEnv(t *testing.T) {
786 forge := newForge(t, map[string][]byte{"/tarball/o/t/v1": tarOf(t, "o-t-x", files{
787 "action.yml": nodeAction, "main.sh": "env | grep -c archive-secret || true\n"})})
788 rec, res := actionRun(t, files{}, " - uses: o/t@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("archive-secret") })
789 if res.Conclusion != ConclusionSuccess {
790 t.Fatalf("%+v", res)
791 }
792 if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "\n0") {
793 t.Errorf("the archive token is not in the environment of actions:\n%s", got)
794 }
795}
796
797func tokenOrigin(token, u string) string {
798 if token == "" {
799 return ""
800 }
801 return originOf(u)
802}
803
804// GO-002: the token goes to the origin the host named and nowhere else, and
805// a redirect never carries it to another origin.
806func TestArchiveTokenOnlyToTheNamedOrigin(t *testing.T) {
807 var gotAuth atomic.Value
808 other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
809 gotAuth.Store(r.Header.Get("Authorization"))
810 _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"}))
811 }))
812 defer other.Close()
813 var firstAuth atomic.Value
814 redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
815 firstAuth.Store(r.Header.Get("Authorization"))
816 http.Redirect(w, r, other.URL+"/t", http.StatusFound)
817 }))
818 defer redir.Close()
819 mk := func(u, token, origin string) Resolver {
820 return func(context.Context, []string) ([]ResolvedAction, error) {
821 return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("d", 40), TarballURL: u, Token: token, AuthOrigin: origin}}, nil
822 }
823 }
824 run := func(r Resolver) Result {
825 _, res := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) {
826 c.Actions.Resolver = r
827 c.Actions.Backoff = func() time.Duration { return time.Millisecond }
828 })
829 return res
830 }
831 // Redirect to another origin (another port on the same host): the header is
832 // sent to the first hop and dropped for the second.
833 if res := run(mk(redir.URL+"/t", "tok", originOf(redir.URL))); res.Conclusion != ConclusionSuccess {
834 t.Fatalf("%+v", res)
835 }
836 if firstAuth.Load() == "" || gotAuth.Load() != "" {
837 t.Errorf("first hop %q, second hop %q", firstAuth.Load(), gotAuth.Load())
838 }
839 // A token without an AuthOrigin, or for another origin, is never sent.
840 gotAuth.Store("unset")
841 if res := run(mk(other.URL+"/t", "tok", "")); res.Conclusion != ConclusionSuccess {
842 t.Fatalf("%+v", res)
843 }
844 if gotAuth.Load() != "" {
845 t.Errorf("no AuthOrigin: sent %q", gotAuth.Load())
846 }
847 gotAuth.Store("unset")
848 _ = run(mk(other.URL+"/t", "tok", "https://other.example"))
849 if gotAuth.Load() != "" {
850 t.Errorf("another origin: sent %q", gotAuth.Load())
851 }
852 // Without a token (github.com) nothing is sent either.
853 gotAuth.Store("unset")
854 _ = run(mk(other.URL+"/t", "", ""))
855 if gotAuth.Load() != "" {
856 t.Errorf("anonymous: sent %q", gotAuth.Load())
857 }
858 // The named origin, with the token.
859 gotAuth.Store("unset")
860 _ = run(mk(other.URL+"/t", "tok", originOf(other.URL)))
861 if got, _ := gotAuth.Load().(string); !strings.HasPrefix(got, "Basic ") {
862 t.Errorf("named origin: %q", got)
863 }
864}
865
866func TestArchiveRedirectPolicy(t *testing.T) {
867 j := &job{}
868 c := j.archiveClient()
869 mkreq := func(u string) *http.Request {
870 r, _ := http.NewRequest(http.MethodGet, u, nil)
871 r.Header.Set("Authorization", "Basic x")
872 return r
873 }
874 for _, tc := range []struct {
875 from, to string
876 keep bool
877 refused bool
878 }{
879 {"https://a.example/x", "https://a.example/y", true, false},
880 {"https://a.example/x", "https://cdn.a.example/y", false, false}, // subdomain
881 {"https://a.example/x", "https://a.example:8443/y", false, false}, // port
882 {"https://a.example/x", "http://a.example/y", false, true}, // downgrade
883 {"http://a.example/x", "https://a.example/y", false, false}, // scheme change
884 {"https://a.example:443/x", "https://a.example/y", true, false}, // same origin
885 {"https://A.example/x", "https://a.example/y", true, false},
886 } {
887 req := mkreq(tc.to)
888 err := c.CheckRedirect(req, []*http.Request{mkreq(tc.from)})
889 if (err != nil) != tc.refused || (req.Header.Get("Authorization") != "") != tc.keep && !tc.refused {
890 t.Errorf("%s -> %s: err=%v header=%q", tc.from, tc.to, err, req.Header.Get("Authorization"))
891 }
892 }
893 if err := c.CheckRedirect(mkreq("https://a.example/"), make([]*http.Request, 10)); err == nil {
894 t.Error("redirect loops must stop")
895 }
896}
897
898// GO-008: Go rebuilds the headers of a redirected request from the first
899// request, so a chain that leaves the origin and stays on the new one, or comes
900// back, would get the Authorization header again on the third request. Real
901// servers, real redirects.
902func TestArchiveRedirectChainNeverReauthorises(t *testing.T) {
903 type hit struct{ path, auth string }
904 var mu sync.Mutex
905 var hits []hit
906 record := func(r *http.Request) {
907 mu.Lock()
908 hits = append(hits, hit{r.URL.Path, r.Header.Get("Authorization")})
909 mu.Unlock()
910 }
911 var a, b *httptest.Server
912 // a (the authorised origin) and b are two servers on the same host and
913 // different ports: the case of Go's host-only comparison.
914 a = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
915 record(r)
916 switch r.URL.Path {
917 case "/start":
918 http.Redirect(w, r, b.URL+"/first", http.StatusFound)
919 case "/back":
920 _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"}))
921 }
922 }))
923 defer a.Close()
924 b = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
925 record(r)
926 switch r.URL.Path {
927 case "/first":
928 http.Redirect(w, r, b.URL+"/second", http.StatusFound)
929 case "/second":
930 http.Redirect(w, r, a.URL+"/back", http.StatusFound) // and back to the origin
931 }
932 }))
933 defer b.Close()
934 res := func(context.Context, []string) ([]ResolvedAction, error) {
935 return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("e", 40), TarballURL: a.URL + "/start", Token: "tok", AuthOrigin: originOf(a.URL)}}, nil
936 }
937 _, r := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) { c.Actions.Resolver = res })
938 if r.Conclusion != ConclusionSuccess {
939 t.Fatalf("%+v", r)
940 }
941 want := []hit{{"/start", "set"}, {"/first", ""}, {"/second", ""}, {"/back", ""}}
942 mu.Lock()
943 defer mu.Unlock()
944 if len(hits) != len(want) {
945 t.Fatalf("hits %v", hits)
946 }
947 for i, h := range hits {
948 if h.path != want[i].path || (h.auth != "") != (want[i].auth != "") {
949 t.Errorf("hit %d: %+v, want %+v (auth %q)", i, h, want[i], h.auth)
950 }
951 }
952}
953
954// GO-012: the token goes to the exact origin, in canonical form.
955func TestTokenAllowedOrigins(t *testing.T) {
956 for _, tc := range []struct {
957 url, origin string
958 ok bool
959 }{
960 {"https://ermite.example/x", "https://ermite.example", true},
961 {"https://ermite.example:443/x", "https://ermite.example", true}, // default port, either way
962 {"https://ermite.example/x", "https://ermite.example:443", true},
963 {"https://ERMITE.example/x", "https://ermite.example", true},
964 {"https://[::1]:8443/x", "https://[::1]:8443", true},
965 {"https://[::1]:8443/x", "https://[::1:8443]", false}, // the collision of the review
966 {"https://[::1:8443]/x", "https://[::1]:8443", false},
967 {"https://[::1]/x", "https://[::1]:443", true},
968 {"https://[::1]/x", "https://[::1]:8443", false},
969 {"http://ermite.example/x", "https://ermite.example", false},
970 {"https://ermite.example:8443/x", "https://ermite.example", false},
971 {"https://sub.ermite.example/x", "https://ermite.example", false},
972 {"https://ermite.example/x", "", false},
973 {"https://ermite.example/x", "not an origin", false},
974 {"https://u:p@ermite.example/x", "https://ermite.example", false},
975 } {
976 if got := tokenAllowed(ResolvedAction{Token: "t", TarballURL: tc.url, AuthOrigin: tc.origin}); got != tc.ok {
977 t.Errorf("url %q origin %q: %v, want %v", tc.url, tc.origin, got, tc.ok)
978 }
979 }
980 if tokenAllowed(ResolvedAction{TarballURL: "https://a.test/x", AuthOrigin: "https://a.test"}) {
981 t.Error("no token, nothing to allow")
982 }
983}
984
985// The markers of the runner around the steps of a composite action
986// (CompositeActionHandler.cs:323, :415-466), as recorded in act-composite-basic,
987// act-composite-nested and act-composite-depth: the id is the dotted context
988// name, a step without an id gets the id the workflow reader generated.
989func TestCompositeStepMarkers(t *testing.T) {
990 tree := files{
991 ".github/actions/mid/action.yml": `name: mid
992runs:
993 using: composite
994 steps:
995 - id: leaf-step
996 shell: bash
997 name: "multi %; ] line"
998 run: echo leaf
999 - uses: ./.github/actions/leaf
1000`,
1001 ".github/actions/leaf/action.yml": `name: leaf
1002runs:
1003 using: composite
1004 steps:
1005 - shell: bash
1006 run: echo "level 2"
1007 - shell: bash
1008 if: false
1009 run: echo skipped
1010 - shell: bash
1011 continue-on-error: true
1012 run: exit 1
1013`,
1014 }
1015 rec, res := actionRun(t, tree, " - id: call\n uses: ./.github/actions/mid\n - uses: ./.github/actions/leaf\n", nil)
1016 if res.Conclusion != ConclusionSuccess {
1017 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
1018 }
1019 var got []string
1020 for _, st := range rec.steps[1:3] {
1021 for _, l := range st.Log {
1022 if strings.HasPrefix(l, "##[start-action") || strings.HasPrefix(l, "##[end-action") {
1023 if !strings.Contains(l, "outcome=skipped") {
1024 l = regexp.MustCompile(`duration_ms=\d+`).ReplaceAllString(l, "duration_ms=N")
1025 }
1026 got = append(got, l)
1027 }
1028 }
1029 }
1030 want := []string{
1031 "##[start-action display=multi %25%3B %5D line;id=call.leaf-step]",
1032 "##[end-action id=call.leaf-step;outcome=success;conclusion=success;duration_ms=N]",
1033 "##[start-action display=Run ./.github/actions/leaf;id=call.__self]",
1034 `##[start-action display=Run echo "level 2";id=call.__self.__run]`,
1035 "##[end-action id=call.__self.__run;outcome=success;conclusion=success;duration_ms=N]",
1036 "##[start-action display=Run echo skipped;id=call.__self.__run_2]",
1037 "##[end-action id=call.__self.__run_2;outcome=skipped;conclusion=skipped;duration_ms=0]",
1038 "##[start-action display=Run exit 1;id=call.__self.__run_3]",
1039 "##[end-action id=call.__self.__run_3;outcome=failure;conclusion=success;duration_ms=N]",
1040 "##[end-action id=call.__self;outcome=success;conclusion=success;duration_ms=N]",
1041 `##[start-action display=Run echo "level 2";id=__self.__run]`,
1042 "##[end-action id=__self.__run;outcome=success;conclusion=success;duration_ms=N]",
1043 "##[start-action display=Run echo skipped;id=__self.__run_2]",
1044 "##[end-action id=__self.__run_2;outcome=skipped;conclusion=skipped;duration_ms=0]",
1045 "##[start-action display=Run exit 1;id=__self.__run_3]",
1046 "##[end-action id=__self.__run_3;outcome=failure;conclusion=success;duration_ms=N]",
1047 }
1048 if strings.Join(got, "\n") != strings.Join(want, "\n") {
1049 t.Errorf("markers:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
1050 }
1051}
1052
1053// A local composite action that uses other local composite actions has one post
1054// step, which prints "Post job cleanup." once (act-composite-depth: 9, 10 and 12
1055// levels, one line on GitHub). What the runner registers while it prepares the
1056// steps of a local composite is keyed by the id of the step of the job.
1057func TestNestedLocalCompositePostsOnce(t *testing.T) {
1058 tree := files{
1059 "c1/action.yml": "name: c1\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo one\n - uses: ./c2\n",
1060 "c2/action.yml": "name: c2\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo two\n - uses: ./c3\n",
1061 "c3/action.yml": "name: c3\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo three\n",
1062 }
1063 rec, res := actionRun(t, tree, " - uses: ./c1\n", nil)
1064 if res.Conclusion != ConclusionSuccess {
1065 t.Fatalf("%+v", res)
1066 }
1067 post := logOf(rec.step(t, "Post Run ./c1"))
1068 if post != "Post job cleanup." {
1069 t.Errorf("post of c1: %q", post)
1070 }
1071 for _, n := range rec.names() {
1072 if strings.HasPrefix(n, "Post ") && n != "Post Run ./c1" {
1073 t.Errorf("unexpected step %q in %v", n, rec.names())
1074 }
1075 }
1076}
1077
1078// A local action's directory is the workspace and the path of the step joined as
1079// .NET's Path.Combine does, without cleaning: github.action_path and the paths in
1080// the manifest errors keep the "./" of "uses: ./.github/actions/x" (recorded in
1081// act-composite-basic and act-manifest-errors).
1082func TestLocalActionPathKeepsDotSlash(t *testing.T) {
1083 tree := files{
1084 ".github/actions/ok/action.yml": "name: ok\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo \"path=$GITHUB_ACTION_PATH\"\n",
1085 ".github/actions/bad/action.yml": "name: bad\nruns:\n using: composite\n steps:\n - run: echo no shell\n",
1086 }
1087 rec, res := actionRun(t, tree, " - uses: ./.github/actions/ok\n - uses: ./.github/actions/bad\n", nil)
1088 if res.Conclusion != ConclusionFailure {
1089 t.Fatalf("%+v", res)
1090 }
1091 ws := regexp.MustCompile(`path=(.*)/\./\.github/actions/ok`).FindStringSubmatch(logOf(rec.steps[1]))
1092 if ws == nil {
1093 t.Fatalf("action_path lost its ./:\n%s", logOf(rec.steps[1]))
1094 }
1095 bad := logOf(rec.steps[2])
1096 if !strings.Contains(bad, "##[error]"+ws[1]+"/./.github/actions/bad/action.yml (Line: 5, Col: 7): Required property is missing: shell") ||
1097 !strings.Contains(bad, "##[error]Failed to load "+ws[1]+"/./.github/actions/bad/action.yml") {
1098 t.Errorf("manifest errors:\n%s", bad)
1099 }
1100}
1101
1102// The markers of a composite step go through the masker like every log line
1103// (ExecutionContext.Write masks tag+message after the marker was built): a
1104// secret in the name of an inner step, from the configuration or from an
1105// ::add-mask:: of an earlier step, never reaches the log.
1106func TestCompositeMarkersAreMasked(t *testing.T) {
1107 tree := files{
1108 ".github/actions/m/action.yml": `name: m
1109runs:
1110 using: composite
1111 steps:
1112 - shell: bash
1113 run: echo "::add-mask::late-secret-""value"
1114 - name: "token ${{ github.token }} and late-secret-value"
1115 shell: bash
1116 run: echo hi
1117 - name: "env ${{ env.TOK }}"
1118 env:
1119 TOK: late-secret-value
1120 shell: bash
1121 run: echo hi
1122`,
1123 }
1124 rec, res := actionRun(t, tree, " - uses: ./.github/actions/m\n", func(c *Config) { c.ContextsJSON = tokenContexts() })
1125 if res.Conclusion != ConclusionSuccess {
1126 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
1127 }
1128 log := logOf(rec.steps[1])
1129 for _, secret := range []string{testToken, "late-secret-value"} {
1130 if strings.Contains(log, secret) {
1131 t.Errorf("%q is in the log:\n%s", secret, log)
1132 }
1133 }
1134 // A secret with a character the marker escapes is masked before the escape.
1135 if got := markerEscaper.Replace(sanitizeDisplayName("a;b")); got != "a%3Bb" {
1136 t.Errorf("escape %q", got)
1137 }
1138 for _, w := range []string{"##[start-action display=token *** and ***;id=", "##[start-action display=env ***;id="} {
1139 if !strings.Contains(log, w) {
1140 t.Errorf("missing %q:\n%s", w, log)
1141 }
1142 }
1143}
1144
1145// An inner step whose env does not evaluate still gets its markers: the error
1146// is logged while the environment is set up, the start marker follows, then the
1147// end marker with the step failed (CompositeActionHandler.cs:296-302, :308-324).
1148// The runner goes on: the condition holds (success() reads action_status) and the
1149// step runs without the env that failed; the end marker says failure with the
1150// elapsed time (:372-390, :431-440, SuccessFunction.cs:28).
1151func TestCompositeEnvErrorKeepsMarkers(t *testing.T) {
1152 tree := files{
1153 ".github/actions/e/action.yml": `name: e
1154runs:
1155 using: composite
1156 steps:
1157 - name: bad env
1158 shell: bash
1159 env:
1160 X: ${{ fromJSON('{') }}
1161 run: echo never
1162`,
1163 }
1164 rec, res := actionRun(t, tree, " - uses: ./.github/actions/e\n", nil)
1165 if res.Conclusion != ConclusionFailure {
1166 t.Fatalf("%+v", res)
1167 }
1168 var got []string
1169 for _, l := range rec.steps[1].Log {
1170 switch {
1171 case strings.HasPrefix(l, "##[error]"):
1172 got = append(got, "error")
1173 case strings.HasPrefix(l, "##[start-action"), strings.HasPrefix(l, "##[end-action"):
1174 got = append(got, l)
1175 case l == "never":
1176 got = append(got, l)
1177 }
1178 }
1179 end := regexp.MustCompile(`^##\[end-action id=__self\.__run;outcome=failure;conclusion=failure;duration_ms=\d+\]$`)
1180 if len(got) != 4 || got[0] != "error" || got[1] != "##[start-action display=bad env;id=__self.__run]" || got[2] != "never" || !end.MatchString(got[3]) {
1181 t.Errorf("markers:\n%s\n%s", strings.Join(got, "\n"), logOf(rec.steps[1]))
1182 }
1183}
1184
1185// A secret that the marker would escape, or that the cut at 1000 characters
1186// would split, is masked before: the display name of an inner step holding it
1187// leaves no part of it in the log.
1188func TestCompositeMarkerMasksBeforeEscapeAndCut(t *testing.T) {
1189 secret := "sec;ret]%value"
1190 long := strings.Repeat("x", 996) + secret // the cut falls inside the secret
1191 tree := files{
1192 ".github/actions/m/action.yml": "name: m\nruns:\n using: composite\n steps:\n" +
1193 " - name: \"pre ${{ env.S }}\"\n shell: bash\n run: echo hi\n" +
1194 " - name: \"" + long + "\"\n shell: bash\n run: echo hi\n",
1195 }
1196 rec, res := actionRun(t, tree, " - run: echo \"::add-mask::"+secret+"\"\n - uses: ./.github/actions/m\n env:\n S: "+secret+"\n", nil)
1197 if res.Conclusion != ConclusionSuccess {
1198 t.Fatalf("%+v\n%s", res, logOf(rec.steps[2]))
1199 }
1200 log := logOf(rec.steps[2])
1201 for _, frag := range []string{"sec;", "sec%3B", "value", "%5D%25", "xsec"} {
1202 if strings.Contains(log, frag) {
1203 t.Errorf("%q is in the log:\n%.1500s", frag, log)
1204 }
1205 }
1206 if !strings.Contains(log, "##[start-action display=pre ***;id=") {
1207 t.Errorf("marker:\n%s", log)
1208 }
1209}
1210
1211// pathCombine is Path.Combine: a rooted part replaces what precedes it, an empty
1212// part is skipped, nothing is cleaned.
1213func TestPathCombine(t *testing.T) {
1214 for _, c := range []struct {
1215 in []string
1216 want string
1217 }{
1218 {[]string{"/w/r", "./.github/a"}, "/w/r/./.github/a"},
1219 {[]string{"/w/r/", "x"}, "/w/r/x"},
1220 {[]string{"/w/r/a", "/etc/x.js"}, "/etc/x.js"},
1221 {[]string{"/w/r/a", "../../x.js"}, "/w/r/a/../../x.js"},
1222 {[]string{"", "x"}, "x"},
1223 {[]string{"/w", ""}, "/w"},
1224 } {
1225 if got := pathCombine(c.in...); got != c.want {
1226 t.Errorf("%q: %q, want %q", c.in, got, c.want)
1227 }
1228 }
1229}
1230
1231// A step of a composite action whose env cannot be evaluated is Failed in the
1232// steps context before its condition is evaluated (CompositeActionHandler.cs:
1233// 296-302, SetStepConclusion :529-533): its own `if` can read the failure and
1234// the step runs. The failed env leaves no partial value: the env is applied
1235// only after the whole mapping evaluated (:290-297).
1236func TestCompositeEnvErrorIsInTheStepsContextBeforeTheCondition(t *testing.T) {
1237 tree := files{
1238 ".github/actions/e/action.yml": `name: e
1239inputs:
1240 tok:
1241 default: ''
1242runs:
1243 using: composite
1244 steps:
1245 - id: bad
1246 if: always() && steps.bad.outcome == 'failure'
1247 shell: bash
1248 env:
1249 GOOD: kept-value
1250 SECRET_LIKE: ${{ inputs.tok }}
1251 X: ${{ fromJSON('{') }}
1252 run: echo "ran good=[$GOOD] secret=[$SECRET_LIKE] x=[$X]"
1253`,
1254 }
1255 const tok = "ghs_partialenvtoken"
1256 rec, res := actionRun(t, tree, " - uses: ./.github/actions/e\n with:\n tok: ${{ secrets.GITHUB_TOKEN }}\n", func(c *Config) {
1257 c.ContextsJSON = strings.Replace(testGitHubContext("j"), `"github":{`, `"github":{"token":"`+tok+`",`, 1)
1258 })
1259 if res.Conclusion != ConclusionFailure {
1260 t.Fatalf("%+v", res)
1261 }
1262 log := logOf(rec.steps[1])
1263 if strings.Contains(log, tok) {
1264 t.Errorf("the secret leaked:\n%s", log)
1265 }
1266 if !strings.Contains(log, "ran good=[] secret=[] x=[]") {
1267 t.Errorf("the step must run without any value of the env that failed:\n%s", log)
1268 }
1269 if strings.Contains(log, "kept-value") && !strings.Contains(log, "good=[]") {
1270 t.Errorf("partial env:\n%s", log)
1271 }
1272 if !strings.Contains(log, "outcome=failure;conclusion=failure") {
1273 t.Errorf("end marker:\n%s", log)
1274 }
1275}
1276
1277// act-composite-basic: the start marker of an inner step carries its display
1278// name evaluated with the contexts of the step, the inputs of the composite
1279// included (CompositeActionHandler.cs:314-318 passes step.ExecutionContext's
1280// ExpressionValues, not the job message's, which hold the inputs of the
1281// workflow): recorded `Run echo "greeting=hello world" >> "$GITHUB_OUTPUT"`.
1282func TestCompositeMarkerNameSeesTheInputsOfTheAction(t *testing.T) {
1283 tree := files{".github/actions/basic/action.yml": `name: basic
1284inputs:
1285 who:
1286 default: world
1287runs:
1288 using: composite
1289 steps:
1290 - id: greet
1291 shell: bash
1292 run: |
1293 echo "greeting=hello ${{ inputs.who }}" >> "$GITHUB_OUTPUT"
1294`}
1295 rec, res := actionRun(t, tree, " - id: basic\n uses: ./.github/actions/basic\n - id: given\n uses: ./.github/actions/basic\n with:\n who: Ermite\n", nil)
1296 if res.Conclusion != ConclusionSuccess {
1297 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
1298 }
1299 for i, w := range []string{
1300 `##[start-action display=Run echo "greeting=hello world" >> "$GITHUB_OUTPUT";id=basic.greet]`,
1301 `##[start-action display=Run echo "greeting=hello Ermite" >> "$GITHUB_OUTPUT";id=given.greet]`,
1302 } {
1303 if got := logOf(rec.steps[1+i]); !strings.Contains(got, w) {
1304 t.Errorf("step %d: missing %q:\n%s", i+1, w, got)
1305 }
1306 }
1307}
1308
1309// act-pre-post, act-post-on-failure: the Post step of a local action prints the
1310// warning about `pre` too, before "Post job cleanup." (ActionRunner.cs:104-109
1311// does not look at the stage). GitHub does the same: its jobs carry six such
1312// annotations (three uses, three Post steps) and five of the six Post logs hold
1313// the line. The recorded first Post step lacks it only because the harness
1314// attributes it to the injected dump step, whose log it drops
1315// (ermite-conformance, movePreHeaderLines).
1316func TestPostStageOfALocalActionWarnsAboutPreToo(t *testing.T) {
1317 act := "name: pp\nruns:\n using: node20\n pre: pre.sh\n main: main.sh\n post: post.sh\n"
1318 tree := files{".github/actions/pp/action.yml": act,
1319 ".github/actions/pp/pre.sh": "echo pre\n", ".github/actions/pp/main.sh": "echo main\n", ".github/actions/pp/post.sh": "echo post\n"}
1320 rec, res := actionRun(t, tree, " - uses: ./.github/actions/pp\n", nil)
1321 if res.Conclusion != ConclusionSuccess {
1322 t.Fatalf("%+v %v", res, rec.names())
1323 }
1324 warn := "##[warning]`pre` execution is not supported for local action from './.github/actions/pp'"
1325 for _, name := range []string{"Run ./.github/actions/pp", "Post Run ./.github/actions/pp"} {
1326 log := rec.step(t, name).Log
1327 if strings.Count(strings.Join(log, "\n"), warn) != 1 {
1328 t.Errorf("%s: want one warning:\n%v", name, log)
1329 }
1330 }
1331 if log := rec.step(t, "Post Run ./.github/actions/pp").Log; len(log) < 2 || log[0] != warn || log[1] != "Post job cleanup." {
1332 t.Errorf("post log starts %v", log[:min(2, len(log))])
1333 }
1334}