1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "bytes"
7 "os"
8 "path/filepath"
9 "strings"
10 "syscall"
11 "testing"
12 "time"
13
14 "github.com/bnema/zerowrap"
15)
16
17const wfHead = "on: push\njobs:\n j:\n runs-on: x\n"
18
19func logOf(s *recStep) string { return strings.Join(s.Log, "\n") }
20
21func TestImplicitStepsAndNumbering(t *testing.T) {
22 needTools(t, "bash")
23 rec, res := runLocal(t, wfHead+" steps:\n - run: echo hi\n - name: second\n run: echo two\n", "j", nil)
24 if res.Conclusion != ConclusionSuccess || !rec.done {
25 t.Fatalf("%+v", res)
26 }
27 want := []string{"Set up job", "Run echo hi", "second", "Complete job"}
28 if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
29 t.Fatalf("steps %v", got)
30 }
31 for i, s := range rec.steps {
32 if s.Num != i+1 || s.Conclusion != ConclusionSuccess {
33 t.Errorf("%+v", s)
34 }
35 }
36 setup := logOf(rec.steps[0])
37 for _, w := range []string{"Current runner version: 'test'", "Prepare workflow directory", "Prepare all required actions", "Complete job name: j"} {
38 if !strings.Contains(setup, w) {
39 t.Errorf("Set up job lacks %q:\n%s", w, setup)
40 }
41 }
42 if got := logOf(rec.steps[3]); got != "Cleaning up orphan processes" {
43 t.Errorf("Complete job: %q", got)
44 }
45 if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "##[endgroup]\nhi") {
46 t.Errorf("log %q", got)
47 }
48}
49
50func TestFailureSkipsAndStatusFunctions(t *testing.T) {
51 needTools(t, "bash")
52 rec, res := runLocal(t, wfHead+` steps:
53 - id: boom
54 run: exit 3
55 - name: skipped by default success()
56 run: echo NO
57 - name: always
58 if: always()
59 run: echo ran-always
60 - name: failure
61 if: failure()
62 run: echo ran-failure
63 - name: cancelled
64 if: cancelled()
65 run: echo NO
66 - name: outcome
67 if: always() && steps.boom.outcome == 'failure' && steps.boom.conclusion == 'failure'
68 run: echo ran-outcome
69`, "j", nil)
70 if res.Conclusion != ConclusionFailure {
71 t.Fatalf("%+v", res)
72 }
73 for name, c := range map[string]Conclusion{
74 "Run exit 3": ConclusionFailure, "skipped by default success()": ConclusionSkipped, "always": ConclusionSuccess,
75 "failure": ConclusionSuccess, "cancelled": ConclusionSkipped, "outcome": ConclusionSuccess,
76 } {
77 if got := rec.step(t, name).Conclusion; got != c {
78 t.Errorf("%s: %s, want %s", name, got, c)
79 }
80 }
81 if got := logOf(rec.step(t, "Run exit 3")); !strings.HasSuffix(got, "##[error]Process completed with exit code 3.") {
82 t.Errorf("%q", got)
83 }
84 if len(rec.step(t, "skipped by default success()").Log) != 0 {
85 t.Error("a skipped step logs nothing")
86 }
87}
88
89func TestEnvPrecedenceAndExpressions(t *testing.T) {
90 needTools(t, "bash")
91 rec, res := runLocal(t, `on: push
92env:
93 A: wf
94 B: ${{ github.event_name }}
95jobs:
96 j:
97 runs-on: x
98 env:
99 A: job
100 C: ${{ github.ref_type }}-c
101 steps:
102 - env:
103 D: ${{ env.A }}-${{ env.C }}
104 A: step
105 run: echo "A=$A B=$B C=$C D=$D"
106 - run: echo "A=$A D=${D-unset}"
107`, "j", nil)
108 if res.Conclusion != ConclusionSuccess {
109 t.Fatalf("%+v", res)
110 }
111 // The step env sees the job env (env.A is "job", not "step"), and the
112 // workflow and job env are evaluated before the steps.
113 if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "A=step B=push C=branch-c D=job-branch-c") {
114 t.Errorf("first step:\n%s", got)
115 }
116 if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "A=job D=unset") {
117 t.Errorf("second step:\n%s", got)
118 }
119}
120
121func TestWorkflowCommands(t *testing.T) {
122 needTools(t, "bash")
123 rec, res := runLocal(t, wfHead+` steps:
124 - id: s
125 run: |
126 echo "::group::My group"
127 echo inside
128 echo "::endgroup::"
129 echo "::warning file=a.txt,line=1::careful"
130 echo "::error::broken"
131 echo "::debug::hidden"
132 echo "::set-output name=old::v"
133 echo "::add-mask::secret"
134 echo "::not-a-command::x"
135 - run: echo "old=${{ steps.s.outputs.old }}"
136`, "j", nil)
137 if res.Conclusion != ConclusionSuccess {
138 t.Fatalf("%+v", res)
139 }
140 got := logOf(rec.steps[1])
141 for _, w := range []string{"##[group]My group\ninside\n##[endgroup]", "##[warning]careful", "##[error]broken", "##[warning]The `set-output` command is deprecated", "::not-a-command::x"} {
142 if !strings.Contains(got, w) {
143 t.Errorf("log lacks %q:\n%s", w, got)
144 }
145 }
146 _, body, _ := strings.Cut(got, "##[endgroup]\n") // after the header, which echoes the script
147 if strings.Contains(body, "hidden") || strings.Contains(body, "::add-mask") || strings.Contains(body, "secret") {
148 t.Errorf("debug and add-mask must not show:\n%s", body)
149 }
150 if !strings.HasSuffix(logOf(rec.steps[2]), "old=v") {
151 t.Errorf("legacy output not visible: %s", logOf(rec.steps[2]))
152 }
153}
154
155func TestCommandFailureFailsTheStep(t *testing.T) {
156 needTools(t, "bash")
157 rec, res := runLocal(t, wfHead+" steps:\n - run: |\n echo '::set-env name=X::1'\n", "j", nil)
158 if res.Conclusion != ConclusionFailure {
159 t.Fatalf("%+v", res)
160 }
161 got := logOf(rec.steps[1])
162 if !strings.Contains(got, "##[error]Unable to process command '::set-env name=X::1' successfully.") || !strings.Contains(got, "The `set-env` command is disabled.") {
163 t.Errorf("%s", got)
164 }
165}
166
167func TestJobOutputs(t *testing.T) {
168 needTools(t, "bash")
169 rec, res := runLocal(t, `on: push
170jobs:
171 j:
172 runs-on: x
173 outputs:
174 a: ${{ steps.one.outputs.v }}
175 empty: ${{ steps.one.outputs.none }}
176 lit: fixed
177 env: ${{ env.E }}
178 env:
179 E: from-env
180 steps:
181 - id: one
182 run: echo "v=1" >> "$GITHUB_OUTPUT"
183 - run: exit 1
184`, "j", nil)
185 if res.Conclusion != ConclusionFailure {
186 t.Fatalf("%+v", res)
187 }
188 // Outputs are collected even after a failure; empty ones are dropped.
189 if len(res.Outputs) != 3 || res.Outputs["a"] != "1" || res.Outputs["lit"] != "fixed" || res.Outputs["env"] != "from-env" {
190 t.Fatalf("%v", res.Outputs)
191 }
192 _ = rec
193}
194
195func TestNotSupportedYet(t *testing.T) {
196 needTools(t, "bash")
197 rec, res := runLocal(t, wfHead+` steps:
198 - uses: actions/checkout@v4
199 - name: after
200 if: always()
201 run: echo still-runs
202 - run: echo skipped
203`, "j", nil)
204 if res.Conclusion != ConclusionFailure {
205 t.Fatalf("%+v", res)
206 }
207 u := rec.step(t, "Run actions/checkout@v4")
208 if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") {
209 t.Fatalf("%+v", u)
210 }
211 if rec.step(t, "after").Conclusion != ConclusionSuccess {
212 t.Error("always() steps still run after a failure")
213 }
214 for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} {
215 rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil)
216 if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") {
217 t.Errorf("%q: %+v %v", f, res2, rec2.names())
218 }
219 }
220}
221
222func TestInvalidWorkflowAndUnknownJob(t *testing.T) {
223 rec, res := runLocal(t, "on: push\njobs: [", "j", nil)
224 if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done {
225 t.Fatalf("%+v %v", res, rec.names())
226 }
227 _, res = runLocal(t, wfHead+" steps: [{run: a}]\n", "nope", nil)
228 if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "no job 'nope'") {
229 t.Fatalf("%+v", res)
230 }
231}
232
233func TestShellVariants(t *testing.T) {
234 needTools(t, "bash", "sh", "python3")
235 rec, res := runLocal(t, wfHead+` steps:
236 - shell: python3 {0}
237 run: print("py-ok")
238 - shell: bash {0}
239 run: echo "no-e $-"
240 - shell: sh
241 run: echo sh-ok
242 - shell: nonexistent-shell {0}
243 run: echo NO
244`, "j", nil)
245 if res.Conclusion != ConclusionFailure {
246 t.Fatalf("%+v", res)
247 }
248 if !strings.HasSuffix(logOf(rec.steps[1]), "py-ok") || !strings.Contains(logOf(rec.steps[2]), "shell: ") {
249 t.Errorf("%s | %s", logOf(rec.steps[1]), logOf(rec.steps[2]))
250 }
251 last := rec.steps[4]
252 if last.Conclusion != ConclusionFailure || !strings.Contains(logOf(last), "##[error]nonexistent-shell: command not found") {
253 t.Errorf("%+v", last)
254 }
255}
256
257func TestGithubPathAndEnvReachNextSteps(t *testing.T) {
258 needTools(t, "bash")
259 rec, _ := runLocal(t, wfHead+` steps:
260 - run: |
261 mkdir -p "$RUNNER_TEMP/bin1" "$RUNNER_TEMP/bin2"
262 printf '#!/bin/sh\necho tool-1\n' > "$RUNNER_TEMP/bin1/tool-ermite"; chmod +x "$RUNNER_TEMP/bin1/tool-ermite"
263 printf '#!/bin/sh\necho tool-2\n' > "$RUNNER_TEMP/bin2/tool-ermite"; chmod +x "$RUNNER_TEMP/bin2/tool-ermite"
264 echo "$RUNNER_TEMP/bin1" >> "$GITHUB_PATH"
265 echo "$RUNNER_TEMP/bin2" >> "$GITHUB_PATH"
266 echo "MULTI<<X" >> "$GITHUB_ENV"; echo "a" >> "$GITHUB_ENV"; echo "b" >> "$GITHUB_ENV"; echo "X" >> "$GITHUB_ENV"
267 - run: |
268 tool-ermite
269 printf 'MULTI=<%s>\n' "$MULTI"
270 echo "ci=$CI actions=$GITHUB_ACTIONS ws=${GITHUB_WORKSPACE##*/} cwd=${PWD##*/}"
271`, "j", nil)
272 got := logOf(rec.steps[2])
273 if !strings.HasSuffix(got, "tool-2\nMULTI=<a\nb>\nci=true actions=true ws=hello cwd=hello") {
274 t.Errorf("%s", got)
275 }
276}
277
278// ---- cancellation and timeouts
279
280func TestCancelSendsSIGINTThenStopsTheJob(t *testing.T) {
281 needTools(t, "bash")
282 c := NewCanceller()
283 started := make(chan struct{})
284 // The script prints "ready" after it installed its trap: the cancel can come
285 // at once.
286 go func() {
287 <-started
288 c.Cancel(CancelUser)
289 }()
290 rec := newRecorder()
291 rep := &signalReporter{recorder: rec, started: started, match: "ready"}
292 home := t.TempDir()
293 layout := NewLayout(home, "hello", home+"/tc")
294 res := Run(c, Config{
295 WorkflowYAML: []byte(wfHead + ` steps:
296 - run: |
297 trap 'echo got-int; exit 130' INT
298 echo ready
299 while :; do sleep 0.05; done
300 - name: after
301 run: echo NO
302 - name: cleanup
303 if: always()
304 run: echo cleanup-ran
305`), JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep,
306 BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home},
307 TermAfter: 300 * time.Millisecond, KillAfter: 300 * time.Millisecond,
308 })
309 if res.Conclusion != ConclusionCancelled || res.RunnerShutdown {
310 t.Fatalf("%+v", res)
311 }
312 run := rec.steps[1]
313 if run.Conclusion != ConclusionCancelled || !strings.Contains(logOf(run), "got-int") || !strings.Contains(logOf(run), "##[error]The operation was canceled.") {
314 t.Fatalf("%s %s", run.Conclusion, logOf(run))
315 }
316 if rec.step(t, "after").Conclusion != ConclusionSkipped {
317 t.Error("after must be skipped")
318 }
319 if got := rec.step(t, "cleanup"); got.Conclusion != ConclusionSuccess || !strings.HasSuffix(logOf(got), "cleanup-ran") {
320 t.Errorf("always() step must run after a cancel: %+v", got)
321 }
322}
323
324// signalReporter closes started when a log line equals match.
325type signalReporter struct {
326 *recorder
327 started chan struct{}
328 match string
329 once bool
330}
331
332func (r *signalReporter) Log(n int, at time.Time, line string) {
333 r.recorder.Log(n, at, line)
334 if !r.once && line == r.match {
335 r.once = true
336 close(r.started)
337 }
338}
339
340func TestCancelEscalatesToTermAndKill(t *testing.T) {
341 needTools(t, "bash")
342 c := NewCanceller()
343 var mu = make(chan struct{}, 8)
344 var got []syscall.Signal
345 started := make(chan struct{})
346 rec := newRecorder()
347 rep := &signalReporter{recorder: rec, started: started, match: "ready"}
348 go func() { <-started; c.Cancel(CancelUser) }()
349 home := t.TempDir()
350 layout := NewLayout(home, "hello", home+"/tc")
351 t0 := time.Now()
352 var times []time.Duration
353 res := Run(c, Config{
354 WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n trap '' INT TERM\n echo ready\n while :; do sleep 0.05; done\n"),
355 JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep,
356 BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home},
357 TermAfter: 250 * time.Millisecond, KillAfter: 250 * time.Millisecond,
358 Signal: func(pid int, sig syscall.Signal) {
359 got = append(got, sig)
360 times = append(times, time.Since(t0))
361 _ = syscall.Kill(pid, sig)
362 mu <- struct{}{}
363 },
364 })
365 if res.Conclusion != ConclusionCancelled {
366 t.Fatalf("%+v", res)
367 }
368 if len(got) != 3 || got[0] != syscall.SIGINT || got[1] != syscall.SIGTERM || got[2] != syscall.SIGKILL {
369 t.Fatalf("signals %v", got)
370 }
371 if d := times[2] - times[0]; d < 450*time.Millisecond || d > 2*time.Second {
372 t.Errorf("kill came %v after the interrupt, want about 500ms", d)
373 }
374}
375
376func TestShutdownCancelSkipsRemainingSteps(t *testing.T) {
377 needTools(t, "bash")
378 c := NewCanceller()
379 started := make(chan struct{})
380 rec := newRecorder()
381 rep := &signalReporter{recorder: rec, started: started, match: "ready"}
382 go func() { <-started; c.Cancel(CancelShutdown) }()
383 home := t.TempDir()
384 layout := NewLayout(home, "hello", home+"/tc")
385 res := Run(c, Config{
386 WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n echo ready\n sleep 30\n - if: always()\n run: echo NO\n"),
387 JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep,
388 BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond,
389 })
390 if res.Conclusion != ConclusionCancelled || !res.RunnerShutdown {
391 t.Fatalf("%+v", res)
392 }
393 // On runner shutdown conditions are not evaluated (StepsRunner.cs:205-208):
394 // even always() steps are skipped.
395 if got := rec.steps[2]; got.Conclusion != ConclusionSkipped {
396 t.Errorf("%+v", got)
397 }
398}
399
400func TestJobTimeout(t *testing.T) {
401 needTools(t, "bash")
402 home := t.TempDir()
403 layout := NewLayout(home, "hello", home+"/tc")
404 rec := newRecorder()
405 cfg := Config{
406 WorkflowYAML: []byte(wfHead + " steps:\n - run: sleep 30\n"),
407 JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rec,
408 BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home}, TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond,
409 }
410 c := NewCanceller()
411 // A one-minute job timeout cannot be waited for: fire it as the timer does.
412 time.AfterFunc(300*time.Millisecond, func() { c.Cancel(CancelTimeout) })
413 res := Run(c, cfg)
414 if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "maximum execution time of 360 minutes") {
415 t.Fatalf("%+v", res)
416 }
417 if rec.steps[1].Conclusion != ConclusionCancelled {
418 t.Errorf("%+v", rec.steps[1])
419 }
420}
421
422func TestStartErrorsAndOrphans(t *testing.T) {
423 needTools(t, "bash")
424 rec, res := runLocal(t, wfHead+` steps:
425 - run: (sleep 60 &) ; echo started-background
426`, "j", nil)
427 if res.Conclusion != ConclusionSuccess {
428 t.Fatalf("%+v", res)
429 }
430 last := logOf(rec.steps[len(rec.steps)-1])
431 if !strings.Contains(last, "Cleaning up orphan processes") || !strings.Contains(last, "Terminate orphan process: pid (") || !strings.Contains(last, ") (sleep)") {
432 t.Errorf("orphan cleanup: %q", last)
433 }
434}
435
436func TestLineWriter(t *testing.T) {
437 var got []string
438 w := &lineWriter{fn: func(l string) { got = append(got, l) }}
439 for _, chunk := range []string{"a\nb\r", "\nc\rd", "\ne\xff", "f\n", "tail"} {
440 _, _ = w.Write([]byte(chunk))
441 }
442 w.Flush()
443 want := []string{"a", "b", "c", "d", "e\ufffdf", "tail"}
444 if strings.Join(got, "|") != strings.Join(want, "|") {
445 t.Fatalf("%q", got)
446 }
447}
448
449func TestParseCommands(t *testing.T) {
450 reg := func(s string) bool { return s == "set-output" || s == "group" || s == "warning" }
451 c, ok := parseCommandV2("::set-output name=a%2Cb,x=y::v%0Aw%25", reg)
452 if !ok || c.Name != "set-output" || c.prop("name") != "a,b" || c.Data != "v\nw%" {
453 t.Fatalf("%+v %v", c, ok)
454 }
455 if c, ok = parseCommandV2(" ::group::title", reg); !ok || c.Data != "title" {
456 t.Fatalf("leading space: %+v", c)
457 }
458 if _, ok = parseCommandV2("::unknown::x", reg); ok {
459 t.Fatal("unregistered command")
460 }
461 if _, ok = parseCommandV2("x ::group::t", reg); ok {
462 t.Fatal("V2 must start the line")
463 }
464 c, ok = parseCommandV1("text ##[set-output name=n;k=v%3Bz]data%5D", reg)
465 if !ok || c.prop("name") != "n" || c.prop("k") != "v;z" || c.Data != "data]" {
466 t.Fatalf("v1: %+v %v", c, ok)
467 }
468}
469
470func TestStopCommands(t *testing.T) {
471 needTools(t, "bash")
472 rec, _ := runLocal(t, wfHead+` steps:
473 - run: |
474 echo "::stop-commands::tok12345"
475 echo "::warning::not-processed"
476 echo "::tok12345::"
477 echo "::warning::processed"
478`, "j", nil)
479 got := logOf(rec.steps[1])
480 if !strings.Contains(got, "::warning::not-processed") || !strings.Contains(got, "##[warning]processed") || !strings.Contains(got, "::stop-commands::tok12345") {
481 t.Errorf("%s", got)
482 }
483}
484
485func TestJobOutputsOverOneMiBFailTheJob(t *testing.T) {
486 needTools(t, "bash")
487 rec, res := runLocal(t, `on: push
488jobs:
489 j:
490 runs-on: x
491 outputs:
492 big: ${{ steps.s.outputs.big }}
493 other: ${{ steps.s.outputs.big }}
494 steps:
495 - id: s
496 run: echo "big=$(head -c 600000 /dev/zero | tr '\0' x)" >> "$GITHUB_OUTPUT"
497`, "j", nil)
498 if res.Conclusion != ConclusionFailure || len(res.Outputs) != 0 {
499 t.Fatalf("%+v", res.Conclusion)
500 }
501 last := rec.steps[len(rec.steps)-1]
502 if last.Name != CompleteJobName || last.Conclusion != ConclusionFailure || !strings.Contains(logOf(last), "##[error]The job outputs are larger than 1 MiB in total.") {
503 t.Fatalf("%s %s", last.Conclusion, logOf(last))
504 }
505}
506
507// A step that plants a link where the executor writes event.json (before the
508// directory exists, or between two steps) must not make it write elsewhere.
509func TestEventJSONIsNeverWrittenThroughALink(t *testing.T) {
510 needTools(t, "bash")
511 victim := filepath.Join(t.TempDir(), "victim")
512 if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil {
513 t.Fatal(err)
514 }
515 rec, res := runLocal(t, wfHead+` steps:
516 - run: |
517 rm -f "$RUNNER_TEMP/_github_workflow/event.json"
518 ln -s "`+victim+`" "$RUNNER_TEMP/_github_workflow/event.json"
519 - run: cat "$GITHUB_EVENT_PATH"
520`, "j", nil)
521 if res.Conclusion != ConclusionSuccess {
522 t.Fatalf("%+v %s", res, logOf(rec.steps[2]))
523 }
524 if b, _ := os.ReadFile(victim); string(b) != "keep" {
525 t.Fatalf("the target of the link was overwritten: %q", b)
526 }
527 if got := logOf(rec.steps[2]); !strings.Contains(got, `"ref": "refs/heads/scn/x/r1"`) {
528 t.Fatalf("the second step does not see a fresh event.json:\n%s", got)
529 }
530}
531
532func TestLineWriterBoundsALineWithoutEnd(t *testing.T) {
533 var got []string
534 w := &lineWriter{fn: func(l string) { got = append(got, l) }}
535 // 2.5 MiB of two-byte characters without a line feed, in odd-sized writes
536 // that cut characters in two.
537 blob := []byte(strings.Repeat("é", (5<<20)/4))
538 for i := 0; i < len(blob); i += 7777 {
539 _, _ = w.Write(blob[i:min(i+7777, len(blob))])
540 }
541 w.Flush()
542 total := 0
543 for _, l := range got {
544 if len(l) > maxLineBytes {
545 t.Fatalf("a line of %d bytes", len(l))
546 }
547 if strings.ContainsRune(l, '\ufffd') {
548 t.Fatal("a character was cut in two")
549 }
550 total += len(l)
551 }
552 if len(got) < 3 || total != len(blob) {
553 t.Fatalf("%d lines, %d bytes of %d", len(got), total, len(blob))
554 }
555}
556
557func TestEnvFlagReadsTheStepEnvironmentNotTheExecutors(t *testing.T) {
558 // ACTIONS_ALLOW_UNSECURE_COMMANDS is read from the environment the steps
559 // get (BaseEnv), not from the process of the executor.
560 t.Setenv("ACTIONS_ALLOW_UNSECURE_COMMANDS", "true")
561 needTools(t, "bash")
562 rec, res := runLocal(t, wfHead+" steps:\n - run: echo '::set-env name=X::1'\n", "j", nil)
563 if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[1]), "The `set-env` command is disabled.") {
564 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
565 }
566 rec, res = runLocal(t, wfHead+" steps:\n - run: echo '::set-env name=X::1'\n - run: echo \"X=$X\"\n", "j", func(c *Config) {
567 c.BaseEnv = append(c.BaseEnv, "ACTIONS_ALLOW_UNSECURE_COMMANDS=true")
568 })
569 if res.Conclusion != ConclusionSuccess || !strings.HasSuffix(logOf(rec.steps[2]), "X=1") {
570 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
571 }
572}
573
574// SIGINT goes to the process the step started, like the official runner
575// (ProcessInvoker.SendSignal: kill(_proc.Id, SIGINT)), not to its group: bash
576// is interrupted but the foreground sleep it waits for is not signalled by it.
577func TestCancelSignalsTheShellNotTheGroup(t *testing.T) {
578 needTools(t, "bash")
579 c := NewCanceller()
580 started := make(chan struct{})
581 rec := newRecorder()
582 rep := &signalReporter{recorder: rec, started: started, match: "ready"}
583 go func() { <-started; c.Cancel(CancelUser) }()
584 home := t.TempDir()
585 layout := NewLayout(home, "hello", home+"/tc")
586 marker := filepath.Join(home, "child-survived")
587 var sigs []syscall.Signal
588 res := Run(c, Config{
589 WorkflowYAML: []byte(wfHead + " steps:\n - run: |\n trap 'echo got-int' INT\n echo ready\n sleep 1 && touch " + marker + "\n"),
590 JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep,
591 BaseEnv: []string{"PATH=" + envPath(), "HOME=" + home},
592 TermAfter: 5 * time.Second, KillAfter: 5 * time.Second,
593 Signal: func(pid int, sig syscall.Signal) {
594 sigs = append(sigs, sig)
595 if err := syscall.Kill(pid, sig); err != nil {
596 t.Error(err)
597 }
598 },
599 })
600 if res.Conclusion != ConclusionCancelled || len(sigs) != 1 || sigs[0] != syscall.SIGINT {
601 t.Fatalf("%+v signals %v", res, sigs)
602 }
603 // bash runs its trap after the foreground child ends: the sleep was not killed.
604 if _, err := os.Stat(marker); err != nil {
605 t.Fatalf("the foreground child was signalled with the shell: %v", err)
606 }
607}
608
609// Deadline is what is left of the timeout when the host sent the job: the
610// executor must not grant the whole timeout again.
611func TestDeadlineOverridesTheTimeout(t *testing.T) {
612 needTools(t, "bash")
613 start := time.Now()
614 rec, res := runLocal(t, wfHead+" steps:\n - run: sleep 30\n", "j", func(c *Config) {
615 c.TimeoutMinutes = 60
616 c.Deadline = 500 * time.Millisecond
617 })
618 if res.Conclusion != ConclusionTimedOut || !strings.Contains(res.Error, "60 minutes") {
619 t.Fatalf("%+v", res)
620 }
621 if time.Since(start) > 10*time.Second || rec.steps[1].Conclusion != ConclusionCancelled {
622 t.Fatalf("took %v: %+v", time.Since(start), rec.steps[1])
623 }
624}
625
626// The env of a step reads its own id in github.action (StepsRunner.cs:118 sets
627// it before the env is evaluated), and the file command paths of the step
628// before it, as the recordings of github.com show for the dump step.
629func TestGithubActionInStepEnvIsTheStepsOwnID(t *testing.T) {
630 needTools(t, "bash")
631 rec, res := runLocal(t, wfHead+` steps:
632 - run: echo one
633 - id: named
634 run: echo "$A|$GITHUB_ACTION|$GITHUB_ARTIFACTS" | sed 's#/[^|]*/##'
635 env:
636 A: ${{ github.action }}
637 - run: echo "$A"
638 env:
639 A: ${{ github.action }}
640`, "j", nil)
641 if res.Conclusion != ConclusionSuccess {
642 t.Fatalf("%+v", res)
643 }
644 if got := rec.steps[2].Log[len(rec.steps[2].Log)-1]; !strings.HasPrefix(got, "named|named|artifacts_") {
645 t.Errorf("named step: %q", got)
646 }
647 if got := rec.steps[3].Log[len(rec.steps[3].Log)-1]; got != "__run_2" {
648 t.Errorf("third step: %q", got)
649 }
650}
651
652// Without a matrix the matrix context is null, as on github.com (toJSON prints
653// null), and matrix.x is still readable (null, so an empty string).
654func TestMatrixContextWithoutMatrixIsNull(t *testing.T) {
655 needTools(t, "bash")
656 rec, res := runLocal(t, wfHead+` steps:
657 - run: echo "json=$J x=[$X] eq=$E"
658 env:
659 J: ${{ toJSON(matrix) }}
660 X: ${{ matrix.x }}
661 E: ${{ matrix == null }}
662`, "j", nil)
663 if res.Conclusion != ConclusionSuccess {
664 t.Fatalf("%+v %v", res, logOf(rec.steps[1]))
665 }
666 if got := rec.steps[1].Log[len(rec.steps[1].Log)-1]; got != "json=null x=[] eq=true" {
667 t.Errorf("%q", got)
668 }
669}
670
671func setupLog(t *testing.T, yaml, job string, tweak func(*Config)) string {
672 t.Helper()
673 rec, res := runLocal(t, yaml, job, tweak)
674 if res.Conclusion != ConclusionSuccess {
675 t.Fatalf("%+v", res)
676 }
677 return logOf(rec.steps[0])
678}
679
680func TestSetUpJobMatchesGitHubHosted(t *testing.T) {
681 needTools(t, "bash")
682 want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", "Contents: read", "Metadata: read",
683 "Packages: read", "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
684 "Prepare all required actions", "Complete job name: j"}, "\n")
685 if got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", nil); got != want {
686 t.Errorf("github-hosted:\n%s", got)
687 }
688}
689
690func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) {
691 needTools(t, "bash")
692 got := setupLog(t, wfHead+" steps:\n - run: echo hi\n", "j", func(c *Config) { c.Runner.Environment = "self-hosted" })
693 host, _ := os.Hostname()
694 if !strings.Contains(got, "Runner name: 'test-runner'\nMachine name: '"+host+"'\n##[group]GITHUB_TOKEN") {
695 t.Errorf("self-hosted:\n%s", got)
696 }
697}
698
699// The expected groups of the whole-token cases, as recorded on github.com
700// (recordings/permissions-display.json: p-read-all, p-write-all).
701const (
702 recordedReadAll = `Actions: read
703ArtifactMetadata: read
704Attestations: read
705Checks: read
706CodeQuality: read
707Contents: read
708Deployments: read
709Discussions: read
710Drives: read
711Issues: read
712Metadata: read
713Models: read
714Packages: read
715Pages: read
716PullRequests: read
717RepositoryProjects: read
718SecurityEvents: read
719Statuses: read
720VulnerabilityAlerts: read`
721 recordedWriteAll = `Actions: write
722ArtifactMetadata: write
723Attestations: write
724Checks: write
725CodeQuality: write
726Contents: write
727CopilotRequests: write
728Deployments: write
729Discussions: write
730Drives: write
731Issues: write
732Metadata: read
733Models: read
734Packages: write
735Pages: write
736PullRequests: write
737RepositoryProjects: write
738SecurityEvents: write
739Statuses: write
740VulnerabilityAlerts: read`
741)
742
743// The GITHUB_TOKEN Permissions group follows the effective permissions of the
744// job (the job's replace the workflow's), as recorded on github.com
745// (recordings/permissions-display.json).
746func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) {
747 needTools(t, "bash")
748 steps := " steps:\n - run: echo hi\n"
749 for _, tc := range []struct {
750 name, yaml, want string
751 }{
752 {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"},
753 {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"},
754 {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"},
755 {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps,
756 "Contents: write\nMetadata: read\nPackages: read"},
757 {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps,
758 "Checks: read\nMetadata: read"},
759 {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps,
760 "Metadata: read\nPullRequests: read"},
761 {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, recordedReadAll},
762 {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps,
763 "Contents: read\nMetadata: read"},
764 {"read-all", "permissions: read-all\n" + wfHead + steps, recordedReadAll},
765 {"write-all", "permissions: write-all\n" + wfHead + steps, recordedWriteAll},
766 {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps,
767 "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"},
768 } {
769 want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", tc.want,
770 "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory",
771 "Prepare all required actions", "Complete job name: j"}, "\n")
772 if got := setupLog(t, tc.yaml, "j", nil); got != want {
773 t.Errorf("%s:\ngot\n%s\nwant\n%s", tc.name, got, want)
774 }
775 }
776}
777
778func TestSetUpJobNamesTheJobLikeGitHub(t *testing.T) {
779 needTools(t, "bash")
780 // recordings/matrix-job-name.json
781 yaml := `on: push
782jobs:
783 default-name:
784 runs-on: x
785 strategy:
786 matrix:
787 os: [a, b]
788 n: [1, 2]
789 steps:
790 - run: echo hi
791 custom-name:
792 name: "custom ${{ matrix.os }} / ${{ matrix.n }} / ${{ github.event_name }}"
793 runs-on: x
794 strategy:
795 matrix:
796 os: [a, b]
797 n: [1]
798 steps:
799 - run: echo hi
800 static-name:
801 name: same name for every leg
802 runs-on: x
803 strategy:
804 matrix:
805 n: [1, 2]
806 steps:
807 - run: echo hi
808 plain:
809 name: "plain ${{ github.event_name }}"
810 runs-on: x
811 steps:
812 - run: echo hi
813`
814 for _, tc := range []struct{ job, matrix, strategy, want string }{
815 {"default-name", `{"os":"b","n":1}`, `{"job-index":2}`, "default-name (b, 1)"},
816 {"default-name", `{"os":"a","n":2}`, `{"job-index":1}`, "default-name (a, 2)"},
817 {"default-name", `{"os":"b","n":2}`, ``, "default-name (b, 2)"},
818 {"custom-name", `{"os":"b","n":1}`, `{"job-index":1}`, "custom b / 1 / push"},
819 {"static-name", `{"n":2}`, `{"job-index":1}`, "same name for every leg (2)"},
820 {"plain", `{}`, ``, "plain push"},
821 {"static-name", `{"n":9}`, ``, "static-name"}, // not one of the combinations: the job key
822 // job-index points to another combination: it is not trusted.
823 {"default-name", `{"os":"a","n":2}`, `{"job-index":0}`, "default-name (a, 2)"},
824 {"default-name", `{"os":"b","n":2}`, `{"job-index":7}`, "default-name (b, 2)"},
825 } {
826 t.Run(tc.want, func(t *testing.T) {
827 got := setupLog(t, yaml, tc.job, func(c *Config) {
828 c.MatrixJSON = tc.matrix
829 ctx := testGitHubContext(tc.job)
830 ctx = strings.TrimSuffix(ctx, "}")
831 ctx += `,"matrix":` + tc.matrix
832 if tc.strategy != "" {
833 ctx += `,"strategy":` + tc.strategy
834 }
835 c.ContextsJSON = ctx + "}"
836 })
837 if !strings.HasSuffix(got, "Complete job name: "+tc.want) {
838 t.Errorf("got:\n%s", got)
839 }
840 })
841 }
842}
843
844func TestJobDisplayNameReadsNeedsVarsAndIgnoresRunnerKeys(t *testing.T) {
845 needTools(t, "bash")
846 yaml := `on: push
847jobs:
848 j:
849 name: "${{ needs.a.outputs.o }}-${{ vars.V }}-${{ github.workspace }}-${{ github.secret_source }}-${{ github.token }}-${{ github.event_name }}"
850 runs-on: x
851 steps:
852 - run: echo hi
853`
854 got := setupLog(t, yaml, "j", func(c *Config) {
855 c.ContextsJSON = `{"github":{"repository":"octo/hello","event_name":"push","job":"j"},"needs":{"a":{"outputs":{"o":"out"}}},"vars":{"V":"v1"}}`
856 })
857 // workspace, secret_source and token are added by the runner: empty for the name.
858 if !strings.HasSuffix(got, "Complete job name: out-v1----push") {
859 t.Errorf("got:\n%s", got)
860 }
861}
862
863func TestJobDisplayNameIncludeOnlyMatrix(t *testing.T) {
864 needTools(t, "bash")
865 yaml := `on: push
866jobs:
867 j:
868 runs-on: x
869 strategy:
870 matrix:
871 include:
872 - os: a
873 n: 1
874 - os: b
875 n: 2
876 steps:
877 - run: echo hi
878`
879 for matrix, want := range map[string]string{`{"os":"b","n":2}`: "j (b, 2)", `{"os":"a","n":1}`: "j (a, 1)"} {
880 got := setupLog(t, yaml, "j", func(c *Config) {
881 c.MatrixJSON = matrix
882 c.ContextsJSON = strings.TrimSuffix(testGitHubContext("j"), "}") + `,"matrix":` + matrix + `}`
883 })
884 if !strings.HasSuffix(got, "Complete job name: "+want) {
885 t.Errorf("%s:\n%s", matrix, got)
886 }
887 }
888}
889
890func TestJobDisplayNameFallsBackToTheKeyWithAWarning(t *testing.T) {
891 needTools(t, "bash")
892 yaml := `on: push
893jobs:
894 j:
895 runs-on: x
896 strategy:
897 matrix:
898 n: [1, 2]
899 steps:
900 - run: echo hi
901`
902 var logs bytes.Buffer
903 got := setupLog(t, yaml, "j", func(c *Config) {
904 c.MatrixJSON = `{"n":5}`
905 c.Log = zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})
906 })
907 if !strings.HasSuffix(got, "Complete job name: j") {
908 t.Errorf("got:\n%s", got)
909 }
910 if !strings.Contains(logs.String(), `"level":"warn"`) || !strings.Contains(logs.String(), "using the job key") {
911 t.Errorf("no warning: %q", logs.String())
912 }
913}