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}