v0.4.4 edcdd4c

actions_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close