1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "connectrpc.com/connect"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19
20 pb "bnema.dev/ermite-api/gen/ermite"
21 "bnema.dev/ermite/internal/domain"
22 "bnema.dev/ermite/internal/ports"
23 "bnema.dev/ermite/internal/usecase"
24)
25
26const oneJob = `on:
27 push:
28 %s
29jobs:
30 j:
31 runs-on: ubuntu-latest
32 steps:
33 - run: echo hi
34`
35
36func workflowWith(filter string) string { return fmt.Sprintf(oneJob, filter) }
37
38// AC3: filters, tags, skip directives, invalid files and deletions.
39func TestCITriggerRules(t *testing.T) {
40 c := newCIHarness(t, nil)
41 c.registerRunner("alpha", "ubuntu-latest")
42 count := func() int { return len(c.runs()) }
43 settle := func() { time.Sleep(300 * time.Millisecond) } // a push that runs nothing leaves nothing to wait for
44
45 // A workflow with a branch filter does not run for another branch.
46 c.push("main", map[string]string{".github/workflows/only-main.yml": workflowWith("branches: [main]"), "src/a.go": "package a\n"}, "")
47 c.waitFor("main runs", func() bool { return count() == 1 })
48 c.push("other", map[string]string{"x.txt": "x\n"}, "")
49 settle()
50 assert.Equal(t, 1, count(), "branches: [main] does not run for other")
51
52 // A paths filter looks at the files of the push.
53 c.push("main", map[string]string{".github/workflows/paths.yml": workflowWith("paths: ['docs/**']")}, "")
54 c.waitFor("only-main runs again for the new commit", func() bool { return count() == 2 })
55 c.push("main", map[string]string{"docs/readme.md": "doc\n"}, "")
56 c.waitFor("both run for docs", func() bool { return count() == 4 })
57 c.push("main", map[string]string{"src/b.go": "package b\n"}, "")
58 c.waitFor("only-main runs for src", func() bool { return count() == 5 })
59 settle()
60 assert.Equal(t, 5, count(), "the paths filter kept paths.yml out")
61
62 // [skip ci] on the head commit.
63 c.push("main", map[string]string{"src/c.go": "package c\n"}, "wip [skip ci]")
64 settle()
65 assert.Equal(t, 5, count())
66
67 // An invalid workflow file is a failed run without a job.
68 c.push("main", map[string]string{".github/workflows/broken.yml": "on: push\njobs:\n j:\n steps: [\n"}, "")
69 c.waitFor("the invalid file gives a run", func() bool {
70 for _, r := range c.runs() {
71 if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
72 return true
73 }
74 }
75 return false
76 })
77 var broken *pb.Run
78 for _, r := range c.runs() {
79 if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
80 broken = r
81 }
82 }
83 assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, broken.GetStatus())
84 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, broken.GetConclusion())
85 assert.NotEmpty(t, broken.GetError())
86 assert.Empty(t, c.getRun(broken.GetId()).GetJobs(), "an invalid file has no job")
87 sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
88 var failed []string
89 for _, ck := range c.checks(sha) {
90 if ck.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE {
91 failed = append(failed, ck.GetName())
92 }
93 }
94 assert.Equal(t, []string{".github/workflows/broken.yml"}, failed, "and a failed check on the commit")
95
96 // A push never fails for CI: the push above succeeded (mustGit). A deletion
97 // runs nothing.
98 before := count()
99 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":other")
100 settle()
101 assert.Equal(t, before, count())
102}
103
104// AC3: tags run tag-filtered workflows; more than 3 tags in one push run none.
105func TestCITags(t *testing.T) {
106 c := newCIHarness(t, nil)
107 c.registerRunner("alpha", "ubuntu-latest")
108 c.push("main", map[string]string{".github/workflows/rel.yml": workflowWith("tags: ['v*']")}, "")
109 settle := func() { time.Sleep(300 * time.Millisecond) }
110 settle()
111 assert.Empty(t, c.runs(), "a tags-only workflow does not run for a branch")
112
113 c.mustGit(c.wt, "tag", "v1")
114 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
115 c.waitFor("a tag runs", func() bool { return len(c.runs()) == 1 })
116 assert.Equal(t, "refs/tags/v1", c.runs()[0].GetRef())
117
118 for _, tag := range []string{"v2", "v3", "v4", "v5"} {
119 c.mustGit(c.wt, "tag", tag)
120 }
121 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v2", "v3", "v4", "v5")
122 settle()
123 assert.Len(t, c.runs(), 1, "more than 3 tags in one push run nothing")
124}
125
126// AC6: cancelling a run asks the runner to stop through WatchJob; a runner that
127// does not answer is ended by the server.
128func TestCICancelReachesTheRunnerAndIsEnforced(t *testing.T) {
129 c := newCIHarness(t, func(cfg *usecase.CIConfig) {
130 cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 400*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
131 })
132 runner := c.registerRunner("alpha", "ubuntu-latest")
133 c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
134 job := runner.await()
135 api := runner.jobAPI(job)
136 _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
137 require.NoError(t, err)
138
139 watch, err := api.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: job.GetJobId()}))
140 require.NoError(t, err)
141 run := c.runs()[0]
142
143 // A reader without ci:write cannot cancel.
144 reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
145 _, err = reader.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
146 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
147
148 _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
149 require.NoError(t, err)
150 // Cancelling again changes nothing.
151 _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
152 require.NoError(t, err)
153
154 var cancel *pb.CancelRequest
155 for watch.Receive() {
156 if m := watch.Msg().GetCancel(); m != nil {
157 cancel = m
158 break
159 }
160 }
161 require.NotNil(t, cancel, "the runner is told")
162 assert.Equal(t, pb.CancelReason_CANCEL_REASON_USER, cancel.GetReason())
163
164 // The queued job that needs it never ran: cancelled at once. The running
165 // one is ended by the server after the grace period.
166 c.waitFor("the server ends the job", func() bool {
167 return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
168 })
169 detail := c.getRun(run.GetId())
170 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, detail.GetRun().GetConclusion())
171 for _, j := range detail.GetJobs() {
172 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion(), j.GetKey())
173 }
174 assert.Contains(t, detail.GetJobs()[0].GetError(), "did not answer")
175
176 // The runner's late CompleteJob returns the server's result.
177 res, err := api.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
178 JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED,
179 }))
180 require.NoError(t, err)
181 assert.False(t, res.Msg.GetApplied())
182
183 // A completed run cannot be cancelled.
184 _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
185 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
186}
187
188// A running job without heartbeats fails with GitHub's lost-runner message.
189func TestCILostRunnerFailsTheJob(t *testing.T) {
190 c := newCIHarness(t, func(cfg *usecase.CIConfig) {
191 cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
192 })
193 runner := c.registerRunner("alpha", "ubuntu-latest")
194 c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
195 job := runner.await()
196 _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
197 require.NoError(t, err)
198
199 c.waitFor("the job is lost", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
200 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
201 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
202 assert.Contains(t, j.GetError(), "The self-hosted runner: alpha lost communication with the server")
203}
204
205// AC6: a re-run is a new attempt of the same run; the failed-only variant keeps
206// the jobs that passed.
207func TestCIRerun(t *testing.T) {
208 c := newCIHarness(t, nil)
209 runner := c.registerRunner("alpha", "ubuntu-latest")
210 c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
211 run := func() *pb.Run { return c.runs()[0] }
212
213 build := runner.await()
214 runner.runJob(build, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
215 test := runner.await()
216 runner.runJob(test, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "red")
217 c.waitFor("the run fails", func() bool { return run().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
218 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run().GetConclusion())
219 id, number := run().GetId(), run().GetNumber()
220
221 // A running run cannot be re-run; a completed one can, by who has ci:write.
222 reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
223 _, err := reader.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
224 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
225
226 res, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
227 require.NoError(t, err)
228 assert.Equal(t, id, res.Msg.GetRun().GetId())
229 assert.Equal(t, number, res.Msg.GetRun().GetNumber())
230 assert.EqualValues(t, 2, res.Msg.GetRun().GetAttempt())
231
232 // build keeps its attempt-1 result; only test runs again.
233 again := runner.await()
234 assert.Equal(t, "test", again.GetJobKey())
235 assert.EqualValues(t, 2, again.GetAttempt())
236 runner.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "green")
237 c.waitFor("attempt 2 passes", func() bool {
238 r := run()
239 return r.GetAttempt() == 2 && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
240 })
241 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, run().GetConclusion())
242 detail := c.getRun(id)
243 require.Len(t, detail.GetJobs(), 2)
244 assert.EqualValues(t, 1, detail.GetJobs()[0].GetAttempt(), "build was kept from attempt 1")
245 assert.EqualValues(t, 2, detail.GetJobs()[1].GetAttempt())
246
247 // Attempt 1 is still readable, with its failure.
248 first, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: id, Attempt: 1}))
249 require.NoError(t, err)
250 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, first.Msg.GetRun().GetConclusion())
251 assert.EqualValues(t, 2, first.Msg.GetLatestAttempt())
252
253 // Re-running a run with nothing failed is refused with failed-only.
254 _, err = c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
255 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
256}
257
258// A matrix is expanded when its job is ready, with fail-fast.
259func TestCIMatrixAndFailFast(t *testing.T) {
260 c := newCIHarness(t, nil)
261 runner := c.registerRunner("alpha", "ubuntu-latest")
262 c.push("main", map[string]string{".github/workflows/m.yml": `on: push
263jobs:
264 build:
265 runs-on: ubuntu-latest
266 strategy:
267 matrix:
268 go: ['1.26', '1.27']
269 steps:
270 - run: echo ${{ matrix.go }}
271 after:
272 needs: build
273 if: always()
274 runs-on: ubuntu-latest
275 steps:
276 - run: echo done
277`}, "")
278 a := runner.await()
279 b := runner.await()
280 assert.Equal(t, "build", a.GetJobKey())
281 assert.JSONEq(t, `{"go":"1.26"}`, a.GetMatrix())
282 assert.JSONEq(t, `{"go":"1.27"}`, b.GetMatrix())
283 assert.Contains(t, a.GetContexts(), `"job-total":2`)
284
285 jobs := c.getRun(c.runs()[0].GetId()).GetJobs()
286 var names []string
287 for _, j := range jobs {
288 names = append(names, j.GetName())
289 }
290 assert.Equal(t, []string{"build (1.26)", "build (1.27)", "after"}, names)
291
292 // The first fails: fail-fast asks the second to stop.
293 apiB := runner.jobAPI(b)
294 _, err := apiB.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: b.GetJobId()}))
295 require.NoError(t, err)
296 watch, err := apiB.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: b.GetJobId()}))
297 require.NoError(t, err)
298 runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
299 var reason pb.CancelReason
300 for watch.Receive() {
301 if m := watch.Msg().GetCancel(); m != nil {
302 reason = m.GetReason()
303 break
304 }
305 }
306 assert.Equal(t, pb.CancelReason_CANCEL_REASON_FAIL_FAST, reason)
307 _, err = apiB.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED}))
308 require.NoError(t, err)
309
310 // "after" has if: always(), so it runs once both ended.
311 after := runner.await()
312 assert.Equal(t, "after", after.GetJobKey())
313 assert.Contains(t, after.GetContexts(), `"result":"failure"`)
314 runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "done")
315 c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
316 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion())
317}
318
319// A job with a feature of a later phase fails clearly, never silently.
320func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) {
321 c := newCIHarness(t, nil)
322 c.registerRunner("alpha", "ubuntu-latest")
323 c.push("main", map[string]string{".github/workflows/u.yml": `on: push
324jobs:
325 j:
326 runs-on: ubuntu-latest
327 steps:
328 - uses: actions/checkout@v4
329`}, "")
330 c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
331 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
332 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
333 assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError())
334}
335
336// A job for a label no runner has is "waiting for a runner" and fails after the
337// queue timeout.
338func TestCIQueueTimeout(t *testing.T) {
339 c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.QueueTimeout, cfg.Tick = 300*time.Millisecond, 20*time.Millisecond })
340 c.registerRunner("alpha", "ubuntu-latest")
341 c.push("main", map[string]string{".github/workflows/q.yml": strings.Replace(workflowWith("branches: [main]"), "ubuntu-latest", "gpu", 1)}, "")
342 c.waitFor("the job fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
343 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
344 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
345 assert.Contains(t, j.GetError(), "was not acquired")
346}
347
348// The runner API validates what it is given.
349func TestCIRunnerRegistrationRules(t *testing.T) {
350 c := newCIHarness(t, nil)
351 reg := func(api *clients, name string, replace bool) error {
352 _, err := api.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
353 Name: name, Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: replace,
354 }))
355 return err
356 }
357 assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(reg(c.api(""), "a", false)))
358 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRepoRead))), "a", false)))
359 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRunnerRegister), "proj")), "a", false)), "a repository-restricted token cannot register a runner")
360 reg1 := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
361 require.NoError(t, reg(reg1, "a", false))
362 assert.Equal(t, connect.CodeAlreadyExists, ciErrCode(reg(reg1, "a", false)))
363 require.NoError(t, reg(reg1, "a", true), "replace")
364 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(reg(reg1, "bad name!", false)))
365
366 list, err := reg1.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
367 require.NoError(t, err)
368 require.Len(t, list.Msg.GetRunners(), 1)
369 assert.Equal(t, []string{"linux", "self-hosted", "x64"}, list.Msg.GetRunners()[0].GetLabels(), "implicit labels, sorted")
370}
371
372// AC2: after a restart of the server, running jobs go on with their tokens,
373// queued jobs stay queued, and a job whose runner kept quiet during the outage
374// is not failed for it.
375func TestCIRestartKeepsRunningAndQueuedJobs(t *testing.T) {
376 c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.HeartbeatDeadline = 2 * time.Second })
377 runner := c.registerRunner("alpha", "ubuntu-latest")
378 c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
379 job := runner.await() // "build"; "test" waits for it
380 api := runner.jobAPI(job)
381 _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
382 require.NoError(t, err)
383 _, err = api.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
384 JobId: job.GetJobId(), Step: 2, Offset: 0, Lines: []*pb.LogLine{{Time: now(), Text: "before the restart"}},
385 }))
386 require.NoError(t, err)
387
388 // Stop the server and start another on the same data directory.
389 cfg, hookExe := c.forge.cfg, binary(t)
390 c.srv.Close()
391 c.forge.Close(c.ctx)
392 f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, ci: func(cc *usecase.CIConfig) { cc.HeartbeatDeadline = 2 * time.Second }})
393 require.NoError(t, err)
394 t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
395 srv := httptest.NewServer(f2.server.Handler())
396 t.Cleanup(srv.Close)
397 api2 := newClients(srv.URL+"/-/api", job.GetJobToken())
398
399 // The job token still works, and the log goes on from where it was.
400 _, err = api2.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
401 require.NoError(t, err, "the job token survives a restart")
402 next, err := api2.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
403 JobId: job.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "after the restart"}},
404 }))
405 require.NoError(t, err)
406 assert.EqualValues(t, 2, next.Msg.GetNextOffset())
407
408 root2 := newClients(srv.URL+"/-/api", adminToken)
409 runs, err := root2.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
410 require.NoError(t, err)
411 require.Len(t, runs.Msg.GetRuns(), 1)
412 assert.Equal(t, pb.CiStatus_CI_STATUS_IN_PROGRESS, runs.Msg.GetRuns()[0].GetStatus())
413
414 // Finish the job on the new server: its dependant is planned and ready.
415 _, err = api2.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
416 JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
417 }))
418 require.NoError(t, err)
419 got := make(chan *pb.Job, 1)
420 go func() {
421 for range 50 {
422 res, err := newClients(srv.URL+"/-/api", c.runnerSecret(runner)).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{}))
423 if err == nil && res.Msg.GetJob() != nil {
424 got <- res.Msg.GetJob()
425 return
426 }
427 }
428 got <- nil
429 }()
430 select {
431 case j := <-got:
432 require.NotNil(t, j, "the runner token and the queue survive a restart")
433 assert.Equal(t, "test", j.GetJobKey())
434 case <-time.After(30 * time.Second):
435 t.Fatal("no job after the restart")
436 }
437}
438
439// AC5: a head without any check merges, and so does one whose checks passed; a
440// merge runs the push workflows on the target.
441func TestCIMergeWithoutChecksAndWithPassingChecks(t *testing.T) {
442 c := newCIHarness(t, nil)
443 runner := c.registerRunner("alpha", "ubuntu-latest")
444 c.push("main", map[string]string{"README": "x\n"}, "")
445 headOf := func(branch string) string { return strings.TrimSpace(c.bareGit("proj", "rev-parse", branch)) }
446 merge := func(n int64, head string) error {
447 _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
448 return err
449 }
450
451 // No workflow on the branch: no check, the merge goes through.
452 c.push("plain", map[string]string{"a.txt": "a\n"}, "")
453 mr1, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "plain", SourceBranch: "plain"}))
454 require.NoError(t, err)
455 require.NoError(t, merge(mr1.Msg.GetMergeRequest().GetNumber(), headOf("plain")))
456
457 // A branch with a workflow: merge after its check passed. The merge itself
458 // triggers the workflow on main (GitHub runs push workflows on a merge).
459 c.mustGit(c.wt, "checkout", "-q", "main")
460 c.mustGit(c.wt, "pull", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
461 c.mustGit(c.wt, "checkout", "-q", "-b", "ci")
462 c.writeFile(c.wt, ".github/workflows/ci.yml", []byte(strings.Replace(workflowWith("branches: [main, ci]"), "echo hi", "echo ci", 1)))
463 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "ci")
464 head := headOf("ci")
465 c.waitFor("the run on ci", func() bool { return len(c.checks(head)) == 1 })
466 mr2, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "ci", SourceBranch: "ci"}))
467 require.NoError(t, err)
468 job := runner.await()
469 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
470 c.waitFor("the check passes", func() bool {
471 cs := c.checks(head)
472 return len(cs) == 1 && cs[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_SUCCESS
473 })
474 require.NoError(t, merge(mr2.Msg.GetMergeRequest().GetNumber(), head))
475
476 // The fast-forward merge moved main to the same commit: the workflow runs
477 // again for main, with the owner as the actor.
478 c.waitFor("the merge triggers a run on main", func() bool {
479 for _, r := range c.runs() {
480 if r.GetRef() == "refs/heads/main" && r.GetSha() == head {
481 return true
482 }
483 }
484 return false
485 })
486 for _, r := range c.runs() {
487 if r.GetRef() == "refs/heads/main" {
488 assert.Equal(t, "owner", r.GetActor())
489 }
490 }
491}
492
493// Repository deletion removes the runs, the checks and the logs; a repository
494// created again starts at run number 1.
495func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
496 c := newCIHarness(t, nil)
497 runner := c.registerRunner("alpha", "ubuntu-latest")
498 sha := c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
499 job := runner.await()
500 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "line")
501 logDir := filepath.Join(c.data, "ci", "logs")
502 entries, err := os.ReadDir(logDir)
503 require.NoError(t, err)
504 require.NotEmpty(t, entries, "the log is a file under ci/logs")
505
506 _, err = c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
507 require.NoError(t, err)
508 _, err = c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
509 assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
510 entries, err = os.ReadDir(logDir)
511 require.NoError(t, err)
512 assert.Empty(t, entries, "the logs are removed with the repository")
513
514 _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("proj"), Visibility: domain.VisibilityPrivate})
515 require.NoError(t, err)
516 assert.Empty(t, c.checks(sha), "no check survives the repository")
517 c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0] + "# again\n"}, "again")
518 c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
519 assert.EqualValues(t, 1, c.runs()[0].GetNumber(), "run numbers start again with the repository")
520 assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
521}
522
523// The events of a run and its jobs are published on the repository topic.
524func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
525 c := newCIHarness(t, nil)
526 runner := c.registerRunner("alpha", "ubuntu-latest")
527 s := c.watch(c.root, "", "repo:proj")
528 c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
529 job := runner.await()
530 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
531
532 var runUpdates, jobUpdates int
533 var lastRun *pb.Run
534 deadline := time.After(10 * time.Second)
535 for lastRun.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
536 select {
537 case ev := <-s.events:
538 if r := ev.GetRunUpdated(); r != nil {
539 runUpdates++
540 if r.GetRun().GetVersion() >= lastRun.GetVersion() {
541 lastRun = r.GetRun()
542 }
543 }
544 if ev.GetJobUpdated() != nil {
545 jobUpdates++
546 }
547 case <-deadline:
548 t.Fatalf("no completed run event: %d run and %d job events", runUpdates, jobUpdates)
549 }
550 }
551 assert.GreaterOrEqual(t, runUpdates, 2)
552 assert.GreaterOrEqual(t, jobUpdates, 2)
553
554 // They are never sent on "home".
555 home := c.watch(c.root, "", "home")
556 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
557 require.NoError(t, err)
558 c.sentinel(t, home, "sentinel")
559}
560
561// A new branch at a commit that already exists, and a branch rewound to an
562// ancestor, bring no new commit: GitHub still runs the push workflows (those
563// without a paths filter, as no file changed).
564func TestCIPushWithoutNewCommitsStillRuns(t *testing.T) {
565 c := newCIHarness(t, nil)
566 c.registerRunner("alpha", "ubuntu-latest")
567 c.push("main", map[string]string{".github/workflows/any.yml": workflowWith("branches: ['**']")}, "")
568 c.waitFor("main runs", func() bool { return len(c.runs()) == 1 })
569 first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
570
571 // A new branch at the commit main already has.
572 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "HEAD:refs/heads/same")
573 c.waitFor("the new branch runs", func() bool { return len(c.runs()) == 2 })
574 assert.Equal(t, "refs/heads/same", c.runs()[0].GetRef())
575 assert.Equal(t, first, c.runs()[0].GetSha())
576
577 // A branch moved forward, then rewound to its ancestor (a forced push).
578 c.push("main", map[string]string{"a.txt": "a\n"}, "")
579 c.waitFor("the push runs", func() bool { return len(c.runs()) == 3 })
580 c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), first+":refs/heads/main")
581 c.waitFor("the rewind runs", func() bool { return len(c.runs()) == 4 })
582 assert.Equal(t, first, c.runs()[0].GetSha())
583}
584
585// CI is on once a runner is registered: before that a push makes no run and no
586// check; after, it does.
587func TestCINoRunnerNoRunsNoChecks(t *testing.T) {
588 c := newCIHarness(t, nil)
589 sha := c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
590 time.Sleep(400 * time.Millisecond)
591 assert.Empty(t, c.runs())
592 assert.Empty(t, c.checks(sha))
593
594 c.registerRunner("alpha", "ubuntu-latest")
595 c.push("main", map[string]string{"a.txt": "a\n"}, "")
596 c.waitFor("a run once a runner exists", func() bool { return len(c.runs()) == 1 })
597}
598
599// A job that fail-fast cancelled while its assignment was not acknowledged is
600// over when the assignment is cancelled: it is not given to a runner again.
601func TestCICancelledUnacknowledgedAssignmentIsNotAssignedAgain(t *testing.T) {
602 c := newCIHarness(t, func(cfg *usecase.CIConfig) {
603 cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
604 })
605 runner := c.registerRunner("alpha", "ubuntu-latest")
606 c.push("main", map[string]string{".github/workflows/m.yml": `on: push
607jobs:
608 build:
609 runs-on: ubuntu-latest
610 strategy:
611 matrix:
612 n: [1, 2]
613 steps:
614 - run: echo ${{ matrix.n }}
615`}, "")
616 a, b := runner.await(), runner.await() // both assigned, neither acknowledged yet
617 assert.NotEqual(t, a.GetJobId(), b.GetJobId())
618
619 runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x") // fail-fast asks b to stop
620
621 c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
622 for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
623 if j.GetId() == b.GetJobId() {
624 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion())
625 }
626 }
627 again, err := runner.fetch("")
628 require.NoError(t, err)
629 assert.Nil(t, again, "a cancelled job is not given again")
630}
631
632// Once the runner acknowledged a job, a retried poll with the same poll_id has
633// nothing to repeat: the plaintext token is no longer kept.
634func TestCIAcknowledgedAssignmentIsNotRepeated(t *testing.T) {
635 c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 150 * time.Millisecond })
636 runner := c.registerRunner("alpha", "ubuntu-latest")
637 c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
638 var job *pb.Job
639 c.waitFor("a job", func() bool {
640 j, err := runner.fetch("poll-9")
641 require.NoError(t, err)
642 job = j
643 return j != nil
644 })
645 _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
646 require.NoError(t, err)
647 again, err := runner.fetch("poll-9")
648 require.NoError(t, err)
649 assert.Nil(t, again, "the acknowledged assignment is not handed out again")
650}
651
652// The push payload follows GitHub's (checked against the recordings of
653// ermite-conformance): 12-character ids in "compare", the commit page for a
654// created ref and a compare range for an update, the default branch of the
655// repository, a tree id, and string counters.
656func TestCIGithubContextOfAPush(t *testing.T) {
657 c := newCIHarness(t, nil)
658 runner := c.registerRunner("alpha", "ubuntu-latest")
659 oneJobWF := strings.Split(ciWorkflow, " test:")[0]
660 first := c.push("main", map[string]string{".github/workflows/ci.yml": oneJobWF}, "")
661 ev := func() (map[string]any, map[string]any) {
662 job := runner.await()
663 var ctxs map[string]map[string]any
664 require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
665 gh := ctxs["github"]
666 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
667 return gh, gh["event"].(map[string]any)
668 }
669
670 gh, event := ev()
671 assert.Equal(t, publicOrigin+"/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")
672 assert.Equal(t, true, event["created"])
673 assert.Equal(t, domain.ZeroOID, event["before"])
674 repo := event["repository"].(map[string]any)
675 assert.Equal(t, "main", repo["default_branch"])
676 assert.Equal(t, "main", repo["master_branch"])
677 assert.Equal(t, "90", gh["retention_days"], "a string, from the configured retention")
678 head := event["head_commit"].(map[string]any)
679 tree := strings.TrimSpace(c.bareGit("proj", "rev-parse", first+"^{tree}"))
680 assert.Equal(t, tree, head["tree_id"])
681
682 second := c.push("main", map[string]string{"a.txt": "a\n"}, "")
683 _, event = ev()
684 assert.Equal(t, publicOrigin+"/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])
685 assert.Equal(t, false, event["created"])
686}