v0.4.3 bdc41cd

actions_artifacts_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"os"
7	"path/filepath"
8	"strings"
9	"testing"
10	"time"
11)
12
13// withResults is a job whose runtime channel serves the cache and the results
14// service.
15func withResults(c *Config) { withCache(c); c.Results = true }
16
17// ACTIONS_RESULTS_URL (NodeScriptActionHandler.cs:71-84) is the runtime URL,
18// for node actions, when the job has the results service only.
19func TestNodeActionGetsResultsURL(t *testing.T) {
20	tree := files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript}
21	for _, tc := range []struct {
22		name  string
23		tweak func(*Config)
24		want  string
25	}{
26		{"results", withResults, "url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[" + cacheTestURL + "]"},
27		{"cache only", withCache, "url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[unset]"},
28		// A channel that serves results only: no cache variables.
29		{"results only", func(c *Config) { withResults(c); c.CacheMode = "write"; c.CacheOff = true },
30			"mode=[unset] url=[] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[" + cacheTestURL + "]"},
31	} {
32		rec, res := actionRun(t, tree, "      - uses: ./.github/actions/e\n", tc.tweak)
33		if res.Conclusion != ConclusionSuccess {
34			t.Fatalf("%s: %+v", tc.name, res)
35		}
36		if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, tc.want) {
37			t.Errorf("%s: want %q in\n%s", tc.name, tc.want, got)
38		}
39	}
40}
41
42// ContainerActionHandler.cs:237-245: a docker action gets ACTIONS_RESULTS_URL
43// too, with the URL of the bridge (its network is not the sandbox's), and no
44// GITHUB_SERVER_URL of its own.
45func TestDockerActionGetsResultsURL(t *testing.T) {
46	for _, tc := range []struct {
47		name  string
48		tweak func(*Config)
49		want  string
50	}{
51		{"results", withResults, "RUN-RT RUNTIME=" + cacheTestContainerURL + " CACHE=" + cacheTestContainerURL + " RESULTS=" + cacheTestContainerURL + " SERVER=https://ermite.example\n"},
52		{"cache only", withCache, "RUN-RT RUNTIME=" + cacheTestContainerURL + " CACHE=" + cacheTestContainerURL + " RESULTS=unset SERVER=https://ermite.example\n"},
53		{"results only", func(c *Config) { withResults(c); c.CacheOff = true }, "RUN-RT RUNTIME=" + cacheTestContainerURL + " CACHE=unset RESULTS=" + cacheTestContainerURL + " SERVER=https://ermite.example\n"},
54		// No bridge in the sandbox: the container gets nothing.
55		{"no bridge", func(c *Config) { withResults(c); c.ContainerCacheURL = "" }, "RUN-RT RUNTIME=unset CACHE=unset RESULTS=unset SERVER=https://ermite.example\n"},
56	} {
57		d, _ := fakeDocker(t, 0)
58		rec, res := actionRun(t, files{}, "      - uses: docker://alpine:3.20\n", func(c *Config) {
59			c.Actions.DockerPath = d
60			tc.tweak(c)
61		})
62		if res.Conclusion != ConclusionSuccess {
63			t.Fatalf("%s: %+v %v", tc.name, res, rec.names())
64		}
65		if got := logOf(rec.steps[2]); !strings.Contains(got, tc.want) {
66			t.Errorf("%s: want %q in\n%s", tc.name, tc.want, got)
67		}
68	}
69}
70
71// A node action in the job container reaches the relay at the bridge URL; a
72// run step there gets no runtime variable.
73func TestContainerStepsGetContainerURL(t *testing.T) {
74	tree := files{
75		".github/actions/x/action.yml": "name: x\nruns:\n  using: node24\n  main: index.js\n",
76		".github/actions/x/index.js":   "x\n",
77	}
78	vars := "ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL ACTIONS_RESULTS_URL GITHUB_SERVER_URL"
79	for _, tc := range []struct {
80		name  string
81		tweak func(*Config)
82		want  string
83	}{
84		{"bridge", withResults, "VAR ACTIONS_RUNTIME_URL=" + cacheTestContainerURL + "\nVAR ACTIONS_CACHE_URL=" + cacheTestContainerURL + "\nVAR ACTIONS_RESULTS_URL=" + cacheTestContainerURL + "\nVAR GITHUB_SERVER_URL=https://ermite.example"},
85		{"no bridge", func(c *Config) { withResults(c); c.ContainerCacheURL = "" }, "VAR ACTIONS_RUNTIME_URL=unset\nVAR ACTIONS_CACHE_URL=unset\nVAR ACTIONS_RESULTS_URL=unset\n"},
86	} {
87		f := newFakeCtr(t)
88		f.set(t, "exec_vars", vars)
89		rec, res := ctrRun(t, f, "    container: ubuntu:24.04\n", "      - name: a\n        uses: ./.github/actions/x\n      - name: r\n        run: echo\n", func(c *Config) {
90			writeFiles(t, c.Layout.Workspace, tree)
91			tc.tweak(c)
92		})
93		if res.Conclusion != ConclusionSuccess {
94			t.Fatalf("%s: %+v\n%s", tc.name, res, logOf(rec.step(t, "a")))
95		}
96		if got := logOf(rec.step(t, "a")); !strings.Contains(got, tc.want) {
97			t.Errorf("%s: want %q in\n%s", tc.name, tc.want, got)
98		}
99		if got := logOf(rec.step(t, "r")); !strings.Contains(got, "VAR ACTIONS_RUNTIME_URL=unset\nVAR ACTIONS_CACHE_URL=unset\nVAR ACTIONS_RESULTS_URL=unset\n") {
100			t.Errorf("%s: run step:\n%s", tc.name, got)
101		}
102	}
103}
104
105// serverAction prints the GITHUB_SERVER_URL of its process and the
106// github.server_url of its inputs, in its pre, main and post stages.
107const serverAction = `name: s
108inputs:
109  ctx:
110    default: "${{ github.server_url }}"
111runs:
112  using: node20
113  pre: pre.sh
114  main: main.sh
115  post: post.sh
116`
117
118func serverScripts(extra string) files {
119	f := files{"action.yml": serverAction}
120	for _, st := range []string{"pre", "main", "post"} {
121		f[st+".sh"] = extra + "echo \"" + st + " env=[$GITHUB_SERVER_URL] ctx=[$INPUT_CTX]\"\n"
122	}
123	return f
124}
125
126// serverForge serves, under each of the given repositories, an action that
127// prints its server URL, at the root and in merge/.
128func serverForge(t *testing.T, names ...string) *fakeForge {
129	tree := files{}
130	for k, v := range serverScripts("") {
131		tree[k], tree["merge/"+k] = v, v
132	}
133	archives := map[string][]byte{}
134	for _, n := range names {
135		archives["/tarball/"+n+"/v4"] = tarOf(t, strings.ReplaceAll(n, "/", "-")+"-x", tree)
136	}
137	return newForge(t, archives)
138}
139
140const ghesURL = "env=[" + artifactsServerURL + "] ctx=[https://ermite.example]"
141const ownURL = "env=[https://ermite.example] ctx=[https://ermite.example]"
142
143// The node steps of upload-artifact and download-artifact (any case, any path
144// inside them) get a GITHUB_SERVER_URL @actions/artifact takes for GitHub, in
145// pre, main and post; the github context and the next steps keep the job's.
146func TestArtifactActionsGetServerURL(t *testing.T) {
147	for _, uses := range []string{"actions/upload-artifact@v4", "actions/download-artifact@v4", "actions/upload-artifact/merge@v4", "Actions/Upload-Artifact@v4", "ACTIONS/download-artifact@v4"} {
148		name, _, _ := strings.Cut(uses, "@")
149		repo := strings.Join(strings.SplitN(name, "/", 3)[:2], "/")
150		forge := serverForge(t, repo)
151		rec, res := actionRun(t, files{}, "      - uses: "+uses+"\n      - run: echo \"after=[$GITHUB_SERVER_URL] [${{ github.server_url }}]\"\n", func(c *Config) {
152			withResults(c)
153			c.Debug = true
154			c.Actions.Resolver = forge.resolver("")
155			c.Actions.Backoff = func() time.Duration { return time.Millisecond }
156		})
157		if res.Conclusion != ConclusionSuccess {
158			t.Fatalf("%s: %+v %v\n%s", uses, res, rec.names(), logOf(rec.steps[0]))
159		}
160		for _, st := range []string{"Pre Run " + uses, "Run " + uses, "Post Run " + uses} {
161			got := logOf(rec.step(t, st))
162			if !strings.Contains(got, ghesURL) {
163				t.Errorf("%s: %s:\n%s", uses, st, got)
164			}
165			if strings.Count(got, "##[debug]GITHUB_SERVER_URL set to "+artifactsServerURL+" for the artifacts service") != 1 {
166				t.Errorf("%s: %s: the override is logged once:\n%s", uses, st, got)
167			}
168		}
169		if got := logOf(rec.step(t, "Run echo \"after=[$GITHUB_SERVER_URL] [https://ermite.example]\"")); !strings.Contains(got, "after=[https://ermite.example] [https://ermite.example]") {
170			t.Errorf("%s: the next step:\n%s", uses, got)
171		}
172	}
173}
174
175// A node action in a composite action is one too.
176func TestArtifactActionInCompositeGetsServerURL(t *testing.T) {
177	forge := serverForge(t, "actions/upload-artifact")
178	tree := files{"c/action.yml": "name: c\nruns:\n  using: composite\n  steps:\n    - uses: actions/upload-artifact@v4\n"}
179	rec, res := actionRun(t, tree, "      - uses: ./c\n", func(c *Config) { withResults(c); c.Actions.Resolver = forge.resolver("") })
180	if res.Conclusion != ConclusionSuccess {
181		t.Fatalf("%+v %v", res, rec.names())
182	}
183	if got := logOf(rec.step(t, "Run ./c")); !strings.Contains(got, "main "+ghesURL) {
184		t.Errorf("composite:\n%s", got)
185	}
186}
187
188// Without the results service, or for another action, nothing changes.
189func TestServerURLUnchangedForOtherActions(t *testing.T) {
190	forge := serverForge(t, "actions/upload-artifact", "octo/tool")
191	for _, tc := range []struct {
192		uses  string
193		tweak func(*Config)
194	}{{"actions/upload-artifact@v4", withCache}, {"octo/tool@v4", withResults}} {
195		rec, res := actionRun(t, files{}, "      - uses: "+tc.uses+"\n", func(c *Config) {
196			tc.tweak(c)
197			c.Debug = true
198			c.Actions.Resolver = forge.resolver("")
199		})
200		if res.Conclusion != ConclusionSuccess {
201			t.Fatalf("%s: %+v %v", tc.uses, res, rec.names())
202		}
203		for _, s := range rec.steps {
204			if got := logOf(s); strings.Contains(got, artifactsServerURL) {
205				t.Errorf("%s: %s:\n%s", tc.uses, s.Name, got)
206			}
207		}
208		if got := logOf(rec.step(t, "Run "+tc.uses)); !strings.Contains(got, "main "+ownURL) {
209			t.Errorf("%s:\n%s", tc.uses, got)
210		}
211	}
212}
213
214// A script that bundles @actions/artifact v2 (its GHES message is in it) gets
215// the URL whatever the action; a run step and a docker action never do.
216func TestBundledArtifactClientGetsServerURL(t *testing.T) {
217	tree := files{}
218	for k, v := range serverScripts("# throw new GHESNotSupportedError('" + ghesMessage + "')\n") {
219		tree["a/"+k] = v
220	}
221	d, _ := fakeDocker(t, 0)
222	rec, res := actionRun(t, tree, "      - uses: ./a\n      - run: echo \"run=[$GITHUB_SERVER_URL]\"\n      - uses: docker://alpine:3.20\n", func(c *Config) {
223		withResults(c)
224		c.Actions.DockerPath = d
225	})
226	if res.Conclusion != ConclusionSuccess {
227		t.Fatalf("%+v %v", res, rec.names())
228	}
229	// A local action has no pre stage (it is not downloaded before the job).
230	for _, st := range []string{"Run ./a", "Post Run ./a"} {
231		if got := logOf(rec.step(t, st)); !strings.Contains(got, ghesURL) {
232			t.Errorf("%s:\n%s", st, got)
233		}
234	}
235	if got := logOf(rec.step(t, "Run echo \"run=[$GITHUB_SERVER_URL]\"")); !strings.Contains(got, "run=[https://ermite.example]") {
236		t.Errorf("run step:\n%s", got)
237	}
238	if got := logOf(rec.step(t, "Run alpine:3.20")); !strings.Contains(got, "SERVER=https://ermite.example\n") {
239		t.Errorf("docker action:\n%s", got)
240	}
241	// The same script without the results service: no change.
242	rec, res = actionRun(t, tree, "      - uses: ./a\n", withCache)
243	if res.Conclusion != ConclusionSuccess {
244		t.Fatalf("%+v %v", res, rec.names())
245	}
246	if got := logOf(rec.step(t, "Run ./a")); !strings.Contains(got, "main "+ownURL) {
247		t.Errorf("without results:\n%s", got)
248	}
249}
250
251// The sniff finds the message across the boundary of two reads, and a file
252// that cannot be read holds nothing.
253func TestFileContains(t *testing.T) {
254	dir := t.TempDir()
255	needle := []byte(ghesMessage)
256	for _, at := range []int{0, sniffChunk - 10, sniffChunk - len(needle), sniffChunk + len(needle) - 1, 3*sniffChunk + 5} {
257		b := make([]byte, at, at+len(needle)+100)
258		for i := range b {
259			b[i] = 'x'
260		}
261		b = append(b, needle...)
262		b = append(b, []byte(strings.Repeat("y", 100))...)
263		p := filepath.Join(dir, "f")
264		if err := os.WriteFile(p, b, 0o644); err != nil {
265			t.Fatal(err)
266		}
267		if !fileContains(p, needle) {
268			t.Errorf("message at %d not found", at)
269		}
270		// One byte short of the message: no match.
271		if err := os.WriteFile(p, b[:at+len(needle)-1], 0o644); err != nil {
272			t.Fatal(err)
273		}
274		if fileContains(p, needle) {
275			t.Errorf("truncated message at %d found", at)
276		}
277	}
278	if fileContains(filepath.Join(dir, "missing"), needle) || fileContains(dir, needle) {
279		t.Error("an unreadable file holds the message")
280	}
281}
282
283// In the job container the replacement comes after the runtime variables of
284// the contexts too, in the environment docker exec passes on.
285func TestContainerNodeStepGetsServerURL(t *testing.T) {
286	tree := files{
287		".github/actions/x/action.yml": "name: x\nruns:\n  using: node24\n  main: index.js\n",
288		".github/actions/x/index.js":   "throw new Error('" + ghesMessage + "')\n",
289	}
290	f := newFakeCtr(t)
291	f.set(t, "exec_vars", "GITHUB_SERVER_URL")
292	rec, res := ctrRun(t, f, "    container: ubuntu:24.04\n", "      - name: a\n        uses: ./.github/actions/x\n      - name: r\n        run: echo\n", func(c *Config) {
293		writeFiles(t, c.Layout.Workspace, tree)
294		withResults(c)
295	})
296	if res.Conclusion != ConclusionSuccess {
297		t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "a")))
298	}
299	if got := logOf(rec.step(t, "a")); !strings.Contains(got, "VAR GITHUB_SERVER_URL="+artifactsServerURL) {
300		t.Errorf("node step:\n%s", got)
301	}
302	if got := logOf(rec.step(t, "r")); !strings.Contains(got, "VAR GITHUB_SERVER_URL=https://ermite.example") {
303		t.Errorf("run step:\n%s", got)
304	}
305}

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