v0.4.1 122e784

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
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}

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