v0.15.0 8a6cfec

ci_reusable_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"strings"
8	"sync"
9	"testing"
10	"time"
11
12	"connectrpc.com/connect"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	pb "bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20	"bnema.dev/ermite/internal/ports/mocks"
21	"bnema.dev/ermite/internal/usecase"
22)
23
24// fakeWorkflows stands in for github.com's file download: no test reaches the
25// network. It answers from a table of "owner/repo@sha:path" to file content.
26type fakeWorkflows struct {
27	m     *mocks.WorkflowFetcher
28	mu    sync.Mutex
29	files map[string]string
30	calls []string
31}
32
33func newFakeWorkflows(t *testing.T) *fakeWorkflows {
34	t.Helper()
35	f := &fakeWorkflows{m: mocks.NewWorkflowFetcher(t), files: map[string]string{}}
36	f.m.EXPECT().FetchWorkflow(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
37		func(_ context.Context, owner, repo, sha, path string, _ int64) ([]byte, error) {
38			f.mu.Lock()
39			defer f.mu.Unlock()
40			key := owner + "/" + repo + "@" + sha + ":" + path
41			f.calls = append(f.calls, key)
42			if s, ok := f.files[key]; ok {
43				return []byte(s), nil
44			}
45			return nil, domain.ErrNotFound
46		}).Maybe()
47	return f
48}
49
50func (f *fakeWorkflows) set(key, content string) {
51	f.mu.Lock()
52	defer f.mu.Unlock()
53	f.files[key] = content
54}
55
56func newReusableHarness(t *testing.T, remote *fakeRemote, wf *fakeWorkflows) (*ciHarness, *fakeRunner) {
57	t.Helper()
58	h := newHarnessWith(t, harnessOpts{env: map[string]string{"ERMITE_SECRETS_KEY": secretsKeyA}}, func(o *options) {
59		o.remoteLister, o.workflowFetcher = remote.m, wf.m
60		o.ci = func(cfg *usecase.CIConfig) { cfg.Tick, cfg.PollTimeout = 20*time.Millisecond, 100*time.Millisecond }
61	})
62	c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
63	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
64	require.NoError(t, err)
65	c.wt = h.workTree("main")
66	return c, c.registerRunner("alpha", "ubuntu-latest")
67}
68
69func jobByKey(c *ciHarness, runID int64, key string) *pb.RunJob {
70	for _, j := range c.getRun(runID).GetJobs() {
71		if j.GetKey() == key {
72			return j
73		}
74	}
75	return nil
76}
77
78// awaitRecord fetches jobs until the one of the record with the key comes. The
79// runner is given the id of the job in its own workflow as the job key, so the
80// record is told apart by its job id.
81func awaitRecord(c *ciHarness, r *fakeRunner, runID int64, key string) *pb.Job {
82	c.t.Helper()
83	for range 20 {
84		j := r.await()
85		for _, rec := range c.getRun(runID).GetJobs() {
86			if rec.GetKey() == key && rec.GetId() == j.GetJobId() {
87				return j
88			}
89		}
90	}
91	c.t.Fatalf("no job %s of run %d", key, runID)
92	return nil
93}
94
95func (c *ciHarness) waitRunDone(runID int64) *pb.Run {
96	c.t.Helper()
97	c.waitFor("the run completes", func() bool {
98		return c.getRun(runID).GetRun().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
99	})
100	return c.getRun(runID).GetRun()
101}
102
103const localCaller = `name: caller
104on: push
105jobs:
106  prep:
107    runs-on: ubuntu-latest
108    outputs:
109      who: ${{ steps.s.outputs.who }}
110    steps:
111      - id: s
112        run: echo who=world >> $GITHUB_OUTPUT
113  call:
114    needs: prep
115    uses: ./.github/workflows/callee.yml
116    with:
117      name: ${{ needs.prep.outputs.who }}
118      count: 3
119    secrets: inherit
120  after:
121    needs: call
122    runs-on: ubuntu-latest
123    steps:
124      - run: echo ${{ needs.call.outputs.greeting }} ${{ needs.call.outputs.total }}
125`
126
127const localCallee = `name: callee
128on:
129  workflow_call:
130    inputs:
131      name:
132        type: string
133        required: true
134      count:
135        type: number
136        default: 1
137      loud:
138        type: boolean
139        default: false
140    outputs:
141      greeting:
142        value: ${{ jobs.hello.outputs.msg }}
143      total:
144        value: ${{ inputs.count }}
145jobs:
146  hello:
147    runs-on: ubuntu-latest
148    outputs:
149      msg: ${{ steps.m.outputs.msg }}
150    steps:
151      - id: m
152        run: echo "msg=hello ${{ inputs.name }} ${{ secrets.TOKEN }} ${{ inputs.loud }}" >> $GITHUB_OUTPUT
153  second:
154    needs: hello
155    runs-on: ubuntu-latest
156    steps:
157      - run: echo ${{ needs.hello.outputs.msg }}
158`
159
160// AC6: a local callee with inputs, secrets: inherit and outputs consumed by a
161// later job of the caller; the callee's jobs are keyed and named after the call.
162func TestCIReusableLocalCalleeWithInputsInheritAndOutputs(t *testing.T) {
163	c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
164	c.setSecret(repoScope("proj"), "TOKEN", "s3cr3t")
165	c.setSecret(repoScope("proj"), "OTHER", "not-sent")
166	c.push("main", map[string]string{".github/workflows/caller.yml": localCaller, ".github/workflows/callee.yml": localCallee}, "")
167
168	var run *pb.Run
169	c.waitFor("the run exists", func() bool {
170		for _, r := range c.runs() {
171			if r.GetWorkflowPath() == ".github/workflows/caller.yml" {
172				run = r
173				return true
174			}
175		}
176		return false
177	})
178	prep := awaitRecord(c, runner, run.GetId(), "prep")
179	runner.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"who": "world"})
180
181	hello := awaitRecord(c, runner, run.GetId(), "call/hello")
182	// The runner is given the called file and the callee's id of the job.
183	assert.Equal(t, ".github/workflows/callee.yml", hello.GetWorkflowPath())
184	assert.Contains(t, hello.GetWorkflowYaml(), "name: callee")
185	assert.Equal(t, "s3cr3t", hello.GetSecrets()["TOKEN"], "inherit passes the caller's secrets")
186	assert.NotContains(t, hello.GetSecrets(), "OTHER", "only the secrets the callee reads")
187	assert.Contains(t, hello.GetContexts(), `"inputs":{"name":"world","count":3,"loud":false}`)
188	assert.Contains(t, hello.GetContexts(), `"workflow_file_path":".github/workflows/callee.yml"`)
189	assert.Equal(t, "call/hello", jobByKey(c, run.GetId(), "call/hello").GetKey())
190	assert.Equal(t, "call / hello", jobByKey(c, run.GetId(), "call/hello").GetName())
191	assert.Equal(t, []string{"call/hello"}, jobByKey(c, run.GetId(), "call/second").GetNeeds())
192	runner.runJob(hello, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"msg": "hello world s3cr3t false"})
193
194	second := awaitRecord(c, runner, run.GetId(), "call/second")
195	assert.Contains(t, second.GetContexts(), `"msg":"hello world s3cr3t false"`)
196	runner.runJob(second, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
197
198	after := awaitRecord(c, runner, run.GetId(), "after")
199	assert.Contains(t, after.GetContexts(), `"greeting":"hello world s3cr3t false"`, "outputs mapped from the callee's jobs")
200	assert.Contains(t, after.GetContexts(), `"total":"3"`)
201	runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
202
203	done := c.waitRunDone(run.GetId())
204	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, done.GetConclusion())
205	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, jobByKey(c, run.GetId(), "call").GetConclusion())
206}
207
208const secretsCallee = `on:
209  workflow_call:
210    secrets:
211      TOKEN:
212        required: true
213      OPTIONAL:
214jobs:
215  j:
216    runs-on: ubuntu-latest
217    steps:
218      - run: echo ${{ secrets.TOKEN }} ${{ secrets.OPTIONAL }}
219`
220
221// D2: secrets are passed only when listed or inherited, renamed by the mapping.
222func TestCIReusableSecretsAreNotPassedUnlessExplicitOrInherited(t *testing.T) {
223	c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
224	c.setSecret(repoScope("proj"), "MY_TOKEN", "mapped-value")
225	c.setSecret(repoScope("proj"), "OPTIONAL", "caller-optional")
226	files := map[string]string{".github/workflows/callee.yml": secretsCallee}
227	caller := func(secrets string) string {
228		return "on: push\njobs:\n  c:\n    uses: ./.github/workflows/callee.yml\n" + secrets
229	}
230
231	// Explicit: TOKEN from MY_TOKEN; OPTIONAL is not passed although the caller has one.
232	files[".github/workflows/explicit.yml"] = caller("    secrets:\n      TOKEN: ${{ secrets.MY_TOKEN }}\n")
233	// Nothing: the callee requires TOKEN, so the call is refused.
234	files[".github/workflows/none.yml"] = caller("")
235	c.push("main", files, "")
236	var explicit, none *pb.Run
237	c.waitFor("two runs", func() bool {
238		for _, r := range c.runs() {
239			switch r.GetWorkflowPath() {
240			case ".github/workflows/explicit.yml":
241				explicit = r
242			case ".github/workflows/none.yml":
243				none = r
244			}
245		}
246		return explicit != nil && none != nil
247	})
248	j := awaitRecord(c, runner, explicit.GetId(), "c/j")
249	assert.Equal(t, map[string]string{"TOKEN": "mapped-value"}, j.GetSecrets())
250	runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
251
252	done := c.waitRunDone(none.GetId())
253	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, done.GetConclusion())
254	assert.Contains(t, jobByKey(c, none.GetId(), "c").GetError(), "Secret TOKEN is required, but not provided while calling.")
255}
256
257// The callee must declare workflow_call; with: is checked against its inputs.
258func TestCIReusableCallErrors(t *testing.T) {
259	c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
260	callee := "on:\n  workflow_call:\n    inputs:\n      n:\n        type: number\n        required: true\n      flag:\n        type: boolean\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo\n"
261	caller := func(with string) string {
262		return "on: push\njobs:\n  c:\n    uses: ./.github/workflows/callee.yml\n" + with
263	}
264	files := map[string]string{
265		".github/workflows/callee.yml":  callee,
266		".github/workflows/nocall.yml":  "on: push\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo\n",
267		".github/workflows/missing.yml": "on: push\njobs:\n  c:\n    uses: ./.github/workflows/nocall.yml\n",
268		".github/workflows/noreq.yml":   caller("    with:\n      flag: true\n"),
269		".github/workflows/badtype.yml": caller("    with:\n      n: abc\n"),
270		".github/workflows/unknown.yml": caller("    with:\n      n: 1\n      zzz: 2\n"),
271		".github/workflows/gone.yml":    "on: push\njobs:\n  c:\n    uses: ./.github/workflows/nothere.yml\n",
272		".github/workflows/ver.yml":     "on: push\njobs:\n  c:\n    uses: ./.github/workflows/callee.yml@main\n",
273	}
274	c.push("main", files, "")
275	want := map[string]string{
276		".github/workflows/missing.yml": "workflow_call key is not defined in the referenced workflow.",
277		".github/workflows/noreq.yml":   "Input n is required, but not provided while calling.",
278		".github/workflows/badtype.yml": "Unexpected value 'abc'",
279		".github/workflows/unknown.yml": "Invalid input, zzz is not defined in the referenced workflow.",
280		".github/workflows/gone.yml":    "Unable to find reusable workflow",
281		".github/workflows/ver.yml":     "cannot specify version when calling local workflows",
282	}
283	runs := map[string]*pb.Run{}
284	c.waitFor("the runs exist", func() bool {
285		for _, r := range c.runs() {
286			runs[r.GetWorkflowPath()] = r
287		}
288		for p := range want {
289			if runs[p] == nil {
290				return false
291			}
292		}
293		return true
294	})
295	for path, text := range want {
296		c.waitRunDone(runs[path].GetId())
297		j := jobByKey(c, runs[path].GetId(), "c")
298		require.NotNil(t, j, path)
299		assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion(), path)
300		assert.Contains(t, j.GetError(), text, path)
301	}
302	assert.Contains(t, jobByKey(c, runs[".github/workflows/noreq.yml"].GetId(), "c").GetError(), "In .github/workflows/noreq.yml (Line: 4, Col: 11): Error from called workflow")
303}
304
305// AC6: depth 5 fails with the documented error; depth 4 runs.
306func TestCIReusableDepth(t *testing.T) {
307	c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
308	files := map[string]string{}
309	for i := 1; i <= 5; i++ {
310		body := "on:\n  workflow_call:\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo leaf\n"
311		if i < 5 {
312			body = "on:\n  workflow_call:\njobs:\n  next:\n    uses: ./.github/workflows/l" + string(rune('0'+i+1)) + ".yml\n"
313		}
314		files[".github/workflows/l"+string(rune('0'+i))+".yml"] = body
315	}
316	files[".github/workflows/top4.yml"] = "on: push\njobs:\n  a:\n    uses: ./.github/workflows/l2.yml\n"
317	files[".github/workflows/top5.yml"] = "on: push\njobs:\n  a:\n    uses: ./.github/workflows/l1.yml\n"
318	delete(files, ".github/workflows/l5.yml")
319	files[".github/workflows/l5.yml"] = "on:\n  workflow_call:\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo leaf\n"
320	c.push("main", files, "")
321	var top4, top5 *pb.Run
322	c.waitFor("both runs", func() bool {
323		for _, r := range c.runs() {
324			switch r.GetWorkflowPath() {
325			case ".github/workflows/top4.yml":
326				top4 = r
327			case ".github/workflows/top5.yml":
328				top5 = r
329			}
330		}
331		return top4 != nil && top5 != nil
332	})
333	// top4: a(1) -> l2(2) -> l3(3) -> l4(4) -> l5 (leaf): the leaf runs at depth 4.
334	leaf := awaitRecord(c, runner, top4.GetId(), "a/next/next/next/j")
335	runner.runJob(leaf, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
336	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(top4.GetId()).GetConclusion())
337
338	// top5: one more level: l1 -> ... -> l5 holds a call at depth 5.
339	c.waitRunDone(top5.GetId())
340	var failed *pb.RunJob
341	for _, j := range c.getRun(top5.GetId()).GetJobs() {
342		if j.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE && strings.HasSuffix(j.GetKey(), "/next") {
343			failed = j
344		}
345	}
346	require.NotNil(t, failed, "the call that is too deep fails")
347	assert.Contains(t, failed.GetError(), "Nested reusable workflow depth exceeded 4.")
348}
349
350const ghCallee = `on:
351  workflow_call:
352    inputs:
353      go-version:
354        type: string
355        default: stable
356jobs:
357  test:
358    runs-on: ubuntu-latest
359    steps:
360      - run: echo ${{ inputs.go-version }}
361`
362
363// AC6: a github.com callee, resolved with the CI-2 ref resolution and fetched
364// at the resolved commit through the fetcher port (a fake here).
365func TestCIReusableFromGitHub(t *testing.T) {
366	remote, wf := newFakeRemote(t), newFakeWorkflows(t)
367	remote.set("acme/gh-actions", ptrRef("refs/heads/main", shaMain))
368	wf.set("acme/gh-actions@"+shaMain+":.github/workflows/go-ci.yml", ghCallee)
369	c, runner := newReusableHarness(t, remote, wf)
370	c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n  ci:\n    uses: acme/gh-actions/.github/workflows/go-ci.yml@main\n    with:\n      go-version: '1.27'\n"}, "")
371	var r *pb.Run
372	c.waitFor("the run", func() bool {
373		if rs := c.runs(); len(rs) > 0 {
374			r = rs[0]
375		}
376		return r != nil
377	})
378	j := awaitRecord(c, runner, r.GetId(), "ci/test")
379	assert.Contains(t, j.GetContexts(), `"inputs":{"go-version":"1.27"}`)
380	assert.Contains(t, j.GetContexts(), `"workflow_ref":"acme/gh-actions/.github/workflows/go-ci.yml@main"`)
381	assert.Contains(t, j.GetContexts(), `"workflow_sha":"`+shaMain+`"`)
382	runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
383	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r.GetId()).GetConclusion())
384	assert.True(t, remote.asked("acme/gh-actions"))
385	wf.mu.Lock()
386	assert.Equal(t, []string{"acme/gh-actions@" + shaMain + ":.github/workflows/go-ci.yml"}, wf.calls)
387	wf.mu.Unlock()
388}
389
390// AC6: an owner repository of the server wins over github.com.
391func TestCIReusableFromAnErmiteRepository(t *testing.T) {
392	remote, wf := newFakeRemote(t), newFakeWorkflows(t)
393	remote.set("owner/shared", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite
394	c, runner := newReusableHarness(t, remote, wf)
395	c.seed("shared", domain.VisibilityPrivate)
396	wt := c.workTree("main")
397	c.writeFile(wt, ".github/workflows/lib.yml", []byte(ghCallee))
398	c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/shared.git"), "main")
399
400	c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n  lib:\n    uses: owner/shared/.github/workflows/lib.yml@main\n"}, "")
401	var r *pb.Run
402	c.waitFor("the run", func() bool {
403		if rs := c.runs(); len(rs) > 0 {
404			r = rs[0]
405		}
406		return r != nil
407	})
408	j := awaitRecord(c, runner, r.GetId(), "lib/test")
409	assert.Contains(t, j.GetContexts(), `"inputs":{"go-version":"stable"}`, "the default")
410	runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
411	c.waitRunDone(r.GetId())
412	assert.False(t, remote.asked("owner/shared"))
413	wf.mu.Lock()
414	assert.Empty(t, wf.calls, "github.com is not asked for an ermite repository")
415	wf.mu.Unlock()
416}
417
418// A matrix on the calling job expands the callee once per leg.
419func TestCIReusableMatrixCaller(t *testing.T) {
420	c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
421	callee := "on:\n  workflow_call:\n    inputs:\n      v:\n        type: string\n        required: true\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo ${{ inputs.v }}\n"
422	caller := "on: push\njobs:\n  c:\n    strategy:\n      matrix:\n        v: [a, b]\n    uses: ./.github/workflows/callee.yml\n    with:\n      v: ${{ matrix.v }}\n"
423	c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": caller}, "")
424	var r *pb.Run
425	c.waitFor("the run", func() bool {
426		for _, x := range c.runs() {
427			if x.GetWorkflowPath() == ".github/workflows/ci.yml" {
428				r = x
429			}
430		}
431		return r != nil
432	})
433	got := map[string]bool{}
434	for range 2 {
435		j := awaitRecord(c, runner, r.GetId(), "c/j")
436		switch {
437		case strings.Contains(j.GetContexts(), `"inputs":{"v":"a"}`):
438			got["a"] = true
439		case strings.Contains(j.GetContexts(), `"inputs":{"v":"b"}`):
440			got["b"] = true
441		}
442		runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
443	}
444	assert.Equal(t, map[string]bool{"a": true, "b": true}, got)
445	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r.GetId()).GetConclusion())
446	names := map[string]bool{}
447	for _, j := range c.getRun(r.GetId()).GetJobs() {
448		names[j.GetName()] = true
449	}
450	assert.True(t, names["c (a) / j"] && names["c (b) / j"], "%v", names)
451}
452
453// D3: the callee can only lower the caller's permissions; a request above is
454// refused with the runner's text, and a lower one is kept.
455func TestCIReusablePermissionsOnlyLowered(t *testing.T) {
456	c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
457	callee := func(perm string) string {
458		return "on:\n  workflow_call:\n" + perm + "jobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo\n"
459	}
460	caller := func(perm, callee string) string {
461		return "on: push\n" + perm + "jobs:\n  c:\n    uses: ./.github/workflows/" + callee + ".yml\n"
462	}
463	c.push("main", map[string]string{
464		".github/workflows/lower.yml":  callee("permissions:\n  contents: read\n"),
465		".github/workflows/higher.yml": callee("permissions:\n  contents: write\n"),
466		".github/workflows/plain.yml":  callee(""),
467		".github/workflows/a.yml":      caller("permissions:\n  contents: write\n  pull-requests: write\n", "lower"),
468		".github/workflows/b.yml":      caller("permissions:\n  contents: read\n", "higher"),
469		".github/workflows/p.yml":      caller("permissions:\n  contents: write\n", "plain"),
470	}, "")
471	runs := map[string]*pb.Run{}
472	c.waitFor("three runs", func() bool {
473		for _, r := range c.runs() {
474			runs[r.GetWorkflowPath()] = r
475		}
476		return runs[".github/workflows/a.yml"] != nil && runs[".github/workflows/b.yml"] != nil && runs[".github/workflows/p.yml"] != nil
477	})
478	lower := awaitRecord(c, runner, runs[".github/workflows/a.yml"].GetId(), "c/j")
479	assert.Equal(t, "read", lower.GetPermissions().GetLevels()["contents"], "the callee lowers")
480	assert.NotContains(t, lower.GetPermissions().GetLevels(), "pull-requests")
481	runner.runJob(lower, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
482
483	plain := awaitRecord(c, runner, runs[".github/workflows/p.yml"].GetId(), "c/j")
484	assert.Equal(t, "write", plain.GetPermissions().GetLevels()["contents"], "a callee without permissions keeps the caller's")
485	runner.runJob(plain, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
486
487	c.waitRunDone(runs[".github/workflows/b.yml"].GetId())
488	var errText string
489	for _, j := range c.getRun(runs[".github/workflows/b.yml"].GetId()).GetJobs() {
490		if j.GetKey() == "c/j" {
491			errText = j.GetError()
492		}
493	}
494	assert.Contains(t, errText, "is requesting 'contents: write', but is only allowed 'contents: read'.")
495}
496
497// D4: the job-level concurrency of a callee's job applies.
498func TestCIReusableCalleeJobConcurrency(t *testing.T) {
499	c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
500	callee := "on:\n  workflow_call:\njobs:\n  j:\n    runs-on: ubuntu-latest\n    concurrency:\n      group: callee-job-group\n    steps:\n      - run: echo\n"
501	caller := "on: push\njobs:\n  c:\n    uses: ./.github/workflows/callee.yml\n"
502	c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": caller}, "")
503	r1 := c.awaitRunOf(".github/workflows/ci.yml", 0)
504	j1 := awaitRecord(c, runner, r1.GetId(), "c/j")
505	assert.Equal(t, "callee-job-group", jobByKey(c, r1.GetId(), "c/j").GetConcurrencyGroup())
506	c.push("main", map[string]string{"x.txt": "1"}, "second")
507	r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId())
508	c.waitFor("the second run's job is pending on the group", func() bool {
509		j := jobByKey(c, r2.GetId(), "c/j")
510		return j != nil && j.GetStatus() == pb.CiStatus_CI_STATUS_PENDING
511	})
512	runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
513	j2 := awaitRecord(c, runner, r2.GetId(), "c/j")
514	runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
515	assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion())
516}
517
518// awaitRunOf waits for the newest run of a workflow file whose id is above after.
519func (c *ciHarness) awaitRunOf(path string, after int64) *pb.Run {
520	c.t.Helper()
521	var r *pb.Run
522	c.waitFor("a run of "+path, func() bool {
523		for _, x := range c.runs() {
524			if x.GetWorkflowPath() == path && x.GetId() > after {
525				r = x
526				return true
527			}
528		}
529		return false
530	})
531	return r
532}
533
534const concCallee = `on:
535  workflow_call:
536    inputs:
537      env:
538        type: string
539        default: prod
540%s
541jobs:
542  j:
543    runs-on: ubuntu-latest
544    steps:
545      - run: echo
546`
547
548func concCaller(top string) string {
549	return "on: push\njobs:\n  c:\n" + top + "    uses: ./.github/workflows/callee.yml\n    with:\n      env: ${{ github.ref_name }}\n"
550}
551
552// D4: the concurrency of the calling job, and the workflow-level concurrency of
553// the called workflow (evaluated with the callee's inputs), hold the whole call:
554// its jobs are not assignable until the call has the group, and the group is
555// freed when the call ends. Both groups are tested in turn.
556func TestCIReusableCallConcurrency(t *testing.T) {
557	for _, tt := range []struct{ name, caller, callee, group string }{
558		{"calling job", "    concurrency:\n      group: call-group\n", "", "call-group"},
559		{"called workflow", "", "concurrency: deploy-${{ inputs.env }}\n", "deploy-main"},
560	} {
561		t.Run(tt.name, func(t *testing.T) {
562			c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
563			callee := strings.Replace(concCallee, "%s\n", tt.callee, 1)
564			files := map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": concCaller(tt.caller)}
565			c.push("main", files, "")
566			r1 := c.awaitRunOf(".github/workflows/ci.yml", 0)
567			j1 := awaitRecord(c, runner, r1.GetId(), "c/j")
568
569			c.push("main", map[string]string{"x.txt": "1"}, "second")
570			r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId())
571			c.waitFor("the second call waits for the group", func() bool {
572				call := jobByKey(c, r2.GetId(), "c")
573				return call != nil && call.GetStatus() == pb.CiStatus_CI_STATUS_PENDING
574			})
575			// Its jobs exist but are not assignable; they show the group.
576			j, err := runner.fetch("")
577			require.NoError(t, err)
578			assert.Nil(t, j, "no job of the second call is given while the first holds the group")
579
580			runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
581			j2 := awaitRecord(c, runner, r2.GetId(), "c/j")
582			runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
583			assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion())
584
585			// Freed with the call: a third call takes it at once.
586			c.push("main", map[string]string{"y.txt": "1"}, "third")
587			r3 := c.awaitRunOf(".github/workflows/ci.yml", r2.GetId())
588			j3 := awaitRecord(c, runner, r3.GetId(), "c/j")
589			runner.runJob(j3, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
590			c.waitRunDone(r3.GetId())
591		})
592	}
593}
594
595// D4: cancel-in-progress on a call cancels the running call: its running job is
596// asked to stop, the call ends cancelled with GitHub's annotation, and the
597// waiting call takes the group.
598func TestCIReusableCallCancelInProgress(t *testing.T) {
599	for _, tt := range []struct{ name, caller, callee string }{
600		{"calling job", "    concurrency:\n      group: Cip\n      cancel-in-progress: true\n", ""},
601		{"called workflow", "", "concurrency:\n  group: Cip\n  cancel-in-progress: true\n"},
602	} {
603		t.Run(tt.name, func(t *testing.T) {
604			c, runner := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
605			callee := strings.Replace(concCallee, "%s\n", tt.callee, 1)
606			c.push("main", map[string]string{".github/workflows/callee.yml": callee, ".github/workflows/ci.yml": concCaller(tt.caller)}, "")
607			r1 := c.awaitRunOf(".github/workflows/ci.yml", 0)
608			j1 := awaitRecord(c, runner, r1.GetId(), "c/j")
609			watch, err := runner.jobAPI(j1).runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: j1.GetJobId()}))
610			require.NoError(t, err)
611
612			c.push("main", map[string]string{"x.txt": "1"}, "second")
613			r2 := c.awaitRunOf(".github/workflows/ci.yml", r1.GetId())
614			var cancel *pb.CancelRequest
615			for watch.Receive() {
616				if m := watch.Msg().GetCancel(); m != nil {
617					cancel = m
618					break
619				}
620			}
621			require.NotNil(t, cancel, "the running job of the call is asked to stop")
622			assert.Contains(t, cancel.GetMessage(), "higher priority waiting request for 'Cip'")
623			assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, c.cancelledCall(r1), "the call ends cancelled")
624
625			_, err = runner.jobAPI(j1).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
626				JobId: j1.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED,
627			}))
628			require.NoError(t, err)
629			j2 := awaitRecord(c, runner, r2.GetId(), "c/j")
630			runner.runJob(j2, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
631			assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(r2.GetId()).GetConclusion())
632			assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, c.waitRunDone(r1.GetId()).GetConclusion())
633		})
634	}
635}
636
637// cancelledCall waits for the call record "c" of a run to end and returns its conclusion.
638func (c *ciHarness) cancelledCall(r *pb.Run) pb.CiConclusion {
639	c.t.Helper()
640	c.waitFor("the call ends", func() bool {
641		j := jobByKey(c, r.GetId(), "c")
642		return j != nil && j.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
643	})
644	return jobByKey(c, r.GetId(), "c").GetConclusion()
645}
646
647// A private repository of the server that the actor cannot read is not a
648// source of reusable workflows: the call fails as for a repository that does
649// not exist, and a reader gets it.
650func TestCIReusableFromAPrivateErmiteRepository(t *testing.T) {
651	for _, tt := range []struct {
652		name  string
653		token func(c *ciHarness) (string, string)
654		reads bool
655	}{
656		{"an owner token", nil, true},
657		{"a token that lists the caller only", func(c *ciHarness) (string, string) {
658			return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
659		}, false},
660		{"a token that lists both", func(c *ciHarness) (string, string) {
661			return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "shared")
662		}, true},
663	} {
664		t.Run(tt.name, func(t *testing.T) {
665			remote := newFakeRemote(t)
666			remote.set("owner/shared", ptrRef("refs/heads/main", shaMain))
667			wf := newFakeWorkflows(t)
668			c, runner := newReusableHarness(t, remote, wf)
669			c.seed("shared", domain.VisibilityPrivate)
670			wt := c.workTree("main")
671			c.writeFile(wt, ".github/workflows/lib.yml", []byte(ghCallee))
672			c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/shared.git"), "main")
673			secret := adminToken
674			if tt.token != nil {
675				secret, _ = tt.token(c)
676			}
677			c.pushAs(secret, "main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n  lib:\n    uses: owner/shared/.github/workflows/lib.yml@main\n"})
678			r := c.awaitRunOf(".github/workflows/ci.yml", 0)
679			if tt.reads {
680				j := awaitRecord(c, runner, r.GetId(), "lib/test")
681				runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
682				c.waitRunDone(r.GetId())
683				return
684			}
685			c.waitRunDone(r.GetId())
686			j := jobByKey(c, r.GetId(), "lib")
687			require.NotNil(t, j)
688			assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
689			assert.Equal(t, "Unable to resolve action `owner/shared@main`, repository not found", j.GetError(),
690				"the text of a repository that is not there, nothing about the private one")
691			assert.False(t, remote.asked("owner/shared"), "never falls through to github.com")
692			wf.mu.Lock()
693			assert.Empty(t, wf.calls)
694			wf.mu.Unlock()
695		})
696	}
697}

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 3

Tags 44

↑↓ moveenter openesc close