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