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}