v0.1.0 cbb8ad6

job_test.go RawHistoryFolder

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}

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