detached 29932f1

test(ci): cover triggers, cancel, re-run, matrix, restart and the merge rule

End-to-end tests with a fake runner over the real handlers: filters
(branches, paths, tags, more than three tags, skip directives, invalid
workflow files, deleted refs), cancel through WatchJob with the server's
enforcement, lost runner, re-run (all jobs and failed jobs only), matrix
with fail-fast and always(), unsupported features, queue timeout, runner
registration rules, restart of the store with running and queued jobs,
repository deletion cleanup, events on the repository topic, merges with
and without checks. Unit tests for the merge rule, check combining, labels,
step ranges, job ids, output limits and token shapes.
commit
29932f1f99e3b70be413919982d38d52644eb66a
author
brice · · 2026-10-12 18:23 UTC
parent
5fbf954
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +771 -17
M internal/adapters/rpc/ci.go +6 -1
@@ -132,7 +132,11 @@ func (s *runnerService) Heartbeat(ctx context.Context, req *connect.Request[pb.H
132132 }
133133
134134 func (s *runnerService) WatchJob(ctx context.Context, req *connect.Request[pb.WatchJobRequest], stream *connect.ServerStream[pb.WatchJobResponse]) error {
135- err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), func(sig ports.JobSignal) error {
135+ // A client's call returns when the response begins: send the headers now,
136+ // not with the first message, which may be 30 seconds away. Connect
137+ // flushes them for a nil message; a failure shows on the first real send.
138+ ready := func() { _ = stream.Send(nil) }
139+ err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), ready, func(sig ports.JobSignal) error {
136140 switch {
137141 case sig.Cancel != nil:
138142 return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Cancel{Cancel: &pb.CancelRequest{
@@ -233,6 +237,7 @@ func (s *runService) GetJobLog(ctx context.Context, req *connect.Request[pb.GetJ
233237 err = s.runs.GetJobLog(ctx, caller(ctx), name, ports.GetJobLogParams{
234238 JobID: req.Msg.GetJobId(), Attempt: req.Msg.GetAttempt(), Offset: req.Msg.GetOffset(), Follow: req.Msg.GetFollow(),
235239 Client: clientFromContext(ctx),
240+ Ready: func() { _ = stream.Send(nil) },
236241 }, func(b ports.LogBatch) error {
237242 out := &pb.GetJobLogResponse{Offset: b.Offset, Lines: make([]*pb.JobLogLine, 0, len(b.Lines))}
238243 for _, l := range b.Lines {
@@ -132,7 +132,11 @@ func (s *runnerService) Heartbeat(ctx context.Context, req *connect.Request[pb.H
132 }132 }
133 133
134 func (s *runnerService) WatchJob(ctx context.Context, req *connect.Request[pb.WatchJobRequest], stream *connect.ServerStream[pb.WatchJobResponse]) error {134 func (s *runnerService) WatchJob(ctx context.Context, req *connect.Request[pb.WatchJobRequest], stream *connect.ServerStream[pb.WatchJobResponse]) error {
135- err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), func(sig ports.JobSignal) error {135+ // A client's call returns when the response begins: send the headers now,
136+ // not with the first message, which may be 30 seconds away. Connect
137+ // flushes them for a nil message; a failure shows on the first real send.
138+ ready := func() { _ = stream.Send(nil) }
139+ err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), ready, func(sig ports.JobSignal) error {
136 switch {140 switch {
137 case sig.Cancel != nil:141 case sig.Cancel != nil:
138 return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Cancel{Cancel: &pb.CancelRequest{142 return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Cancel{Cancel: &pb.CancelRequest{
@@ -233,6 +237,7 @@ func (s *runService) GetJobLog(ctx context.Context, req *connect.Request[pb.GetJ
233 err = s.runs.GetJobLog(ctx, caller(ctx), name, ports.GetJobLogParams{237 err = s.runs.GetJobLog(ctx, caller(ctx), name, ports.GetJobLogParams{
234 JobID: req.Msg.GetJobId(), Attempt: req.Msg.GetAttempt(), Offset: req.Msg.GetOffset(), Follow: req.Msg.GetFollow(),238 JobID: req.Msg.GetJobId(), Attempt: req.Msg.GetAttempt(), Offset: req.Msg.GetOffset(), Follow: req.Msg.GetFollow(),
235 Client: clientFromContext(ctx),239 Client: clientFromContext(ctx),
240+ Ready: func() { _ = stream.Send(nil) },
236 }, func(b ports.LogBatch) error {241 }, func(b ports.LogBatch) error {
237 out := &pb.GetJobLogResponse{Offset: b.Offset, Lines: make([]*pb.JobLogLine, 0, len(b.Lines))}242 out := &pb.GetJobLogResponse{Offset: b.Offset, Lines: make([]*pb.JobLogLine, 0, len(b.Lines))}
238 for _, l := range b.Lines {243 for _, l := range b.Lines {
M internal/app/ci_e2e_test.go +8 -4
@@ -82,16 +82,20 @@ func (c *ciHarness) registerRunner(name string, labels ...string) *fakeRunner {
8282 Name: name, Labels: labels, Version: "devel", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux",
8383 }))
8484 require.NoError(c.t, err)
85- return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), api: c.api(res.Msg.GetRunnerToken())}
85+ return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), secret: res.Msg.GetRunnerToken(), api: c.api(res.Msg.GetRunnerToken())}
8686 }
8787
8888 // fakeRunner is the runner side of the protocol, as a test.
8989 type fakeRunner struct {
90- c *ciHarness
91- id string
92- api *clients
90+ c *ciHarness
91+ id string
92+ secret string
93+ api *clients
9394 }
9495
96+// runnerSecret is the runner token of a fake runner.
97+func (c *ciHarness) runnerSecret(r *fakeRunner) string { return r.secret }
98+
9599 // fetch polls once for a job.
96100 func (r *fakeRunner) fetch(pollID string) (*pb.Job, error) {
97101 res, err := r.api.runner.FetchJob(r.c.ctx, connect.NewRequest(&pb.FetchJobRequest{PollId: pollID}))
@@ -82,16 +82,20 @@ func (c *ciHarness) registerRunner(name string, labels ...string) *fakeRunner {
82 Name: name, Labels: labels, Version: "devel", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux",82 Name: name, Labels: labels, Version: "devel", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux",
83 }))83 }))
84 require.NoError(c.t, err)84 require.NoError(c.t, err)
85- return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), api: c.api(res.Msg.GetRunnerToken())}85+ return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), secret: res.Msg.GetRunnerToken(), api: c.api(res.Msg.GetRunnerToken())}
86 }86 }
87 87
88 // fakeRunner is the runner side of the protocol, as a test.88 // fakeRunner is the runner side of the protocol, as a test.
89 type fakeRunner struct {89 type fakeRunner struct {
90- c *ciHarness90+ c *ciHarness
91- id string91+ id string
92- api *clients92+ secret string
93+ api *clients
93 }94 }
94 95
96+// runnerSecret is the runner token of a fake runner.
97+func (c *ciHarness) runnerSecret(r *fakeRunner) string { return r.secret }
98+
95 // fetch polls once for a job.99 // fetch polls once for a job.
96 func (r *fakeRunner) fetch(pollID string) (*pb.Job, error) {100 func (r *fakeRunner) fetch(pollID string) (*pb.Job, error) {
97 res, err := r.api.runner.FetchJob(r.c.ctx, connect.NewRequest(&pb.FetchJobRequest{PollId: pollID}))101 res, err := r.api.runner.FetchJob(r.c.ctx, connect.NewRequest(&pb.FetchJobRequest{PollId: pollID}))
A internal/app/ci_flow_test.go +558 -0
@@ -0,0 +1,558 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "fmt"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+const oneJob = `on:
26+ push:
27+ %s
28+jobs:
29+ j:
30+ runs-on: ubuntu-latest
31+ steps:
32+ - run: echo hi
33+`
34+
35+func workflowWith(filter string) string { return fmt.Sprintf(oneJob, filter) }
36+
37+// AC3: filters, tags, skip directives, invalid files and deletions.
38+func TestCITriggerRules(t *testing.T) {
39+ c := newCIHarness(t, nil)
40+ c.registerRunner("alpha", "ubuntu-latest")
41+ count := func() int { return len(c.runs()) }
42+ settle := func() { time.Sleep(300 * time.Millisecond) } // a push that runs nothing leaves nothing to wait for
43+
44+ // A workflow with a branch filter does not run for another branch.
45+ c.push("main", map[string]string{".github/workflows/only-main.yml": workflowWith("branches: [main]"), "src/a.go": "package a\n"}, "")
46+ c.waitFor("main runs", func() bool { return count() == 1 })
47+ c.push("other", map[string]string{"x.txt": "x\n"}, "")
48+ settle()
49+ assert.Equal(t, 1, count(), "branches: [main] does not run for other")
50+
51+ // A paths filter looks at the files of the push.
52+ c.push("main", map[string]string{".github/workflows/paths.yml": workflowWith("paths: ['docs/**']")}, "")
53+ c.waitFor("only-main runs again for the new commit", func() bool { return count() == 2 })
54+ c.push("main", map[string]string{"docs/readme.md": "doc\n"}, "")
55+ c.waitFor("both run for docs", func() bool { return count() == 4 })
56+ c.push("main", map[string]string{"src/b.go": "package b\n"}, "")
57+ c.waitFor("only-main runs for src", func() bool { return count() == 5 })
58+ settle()
59+ assert.Equal(t, 5, count(), "the paths filter kept paths.yml out")
60+
61+ // [skip ci] on the head commit.
62+ c.push("main", map[string]string{"src/c.go": "package c\n"}, "wip [skip ci]")
63+ settle()
64+ assert.Equal(t, 5, count())
65+
66+ // An invalid workflow file is a failed run without a job.
67+ c.push("main", map[string]string{".github/workflows/broken.yml": "on: push\njobs:\n j:\n steps: [\n"}, "")
68+ c.waitFor("the invalid file gives a run", func() bool {
69+ for _, r := range c.runs() {
70+ if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
71+ return true
72+ }
73+ }
74+ return false
75+ })
76+ var broken *pb.Run
77+ for _, r := range c.runs() {
78+ if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
79+ broken = r
80+ }
81+ }
82+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, broken.GetStatus())
83+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, broken.GetConclusion())
84+ assert.NotEmpty(t, broken.GetError())
85+ assert.Empty(t, c.getRun(broken.GetId()).GetJobs(), "an invalid file has no job")
86+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
87+ var failed []string
88+ for _, ck := range c.checks(sha) {
89+ if ck.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE {
90+ failed = append(failed, ck.GetName())
91+ }
92+ }
93+ assert.Equal(t, []string{".github/workflows/broken.yml"}, failed, "and a failed check on the commit")
94+
95+ // A push never fails for CI: the push above succeeded (mustGit). A deletion
96+ // runs nothing.
97+ before := count()
98+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":other")
99+ settle()
100+ assert.Equal(t, before, count())
101+}
102+
103+// AC3: tags run tag-filtered workflows; more than 3 tags in one push run none.
104+func TestCITags(t *testing.T) {
105+ c := newCIHarness(t, nil)
106+ c.registerRunner("alpha", "ubuntu-latest")
107+ c.push("main", map[string]string{".github/workflows/rel.yml": workflowWith("tags: ['v*']")}, "")
108+ settle := func() { time.Sleep(300 * time.Millisecond) }
109+ settle()
110+ assert.Empty(t, c.runs(), "a tags-only workflow does not run for a branch")
111+
112+ c.mustGit(c.wt, "tag", "v1")
113+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
114+ c.waitFor("a tag runs", func() bool { return len(c.runs()) == 1 })
115+ assert.Equal(t, "refs/tags/v1", c.runs()[0].GetRef())
116+
117+ for _, tag := range []string{"v2", "v3", "v4", "v5"} {
118+ c.mustGit(c.wt, "tag", tag)
119+ }
120+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v2", "v3", "v4", "v5")
121+ settle()
122+ assert.Len(t, c.runs(), 1, "more than 3 tags in one push run nothing")
123+}
124+
125+// AC6: cancelling a run asks the runner to stop through WatchJob; a runner that
126+// does not answer is ended by the server.
127+func TestCICancelReachesTheRunnerAndIsEnforced(t *testing.T) {
128+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
129+ cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 400*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
130+ })
131+ runner := c.registerRunner("alpha", "ubuntu-latest")
132+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
133+ job := runner.await()
134+ api := runner.jobAPI(job)
135+ _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
136+ require.NoError(t, err)
137+
138+ watch, err := api.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: job.GetJobId()}))
139+ require.NoError(t, err)
140+ run := c.runs()[0]
141+
142+ // A reader without ci:write cannot cancel.
143+ reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
144+ _, err = reader.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
145+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
146+
147+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
148+ require.NoError(t, err)
149+ // Cancelling again changes nothing.
150+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
151+ require.NoError(t, err)
152+
153+ var cancel *pb.CancelRequest
154+ for watch.Receive() {
155+ if m := watch.Msg().GetCancel(); m != nil {
156+ cancel = m
157+ break
158+ }
159+ }
160+ require.NotNil(t, cancel, "the runner is told")
161+ assert.Equal(t, pb.CancelReason_CANCEL_REASON_USER, cancel.GetReason())
162+
163+ // The queued job that needs it never ran: cancelled at once. The running
164+ // one is ended by the server after the grace period.
165+ c.waitFor("the server ends the job", func() bool {
166+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
167+ })
168+ detail := c.getRun(run.GetId())
169+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, detail.GetRun().GetConclusion())
170+ for _, j := range detail.GetJobs() {
171+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion(), j.GetKey())
172+ }
173+ assert.Contains(t, detail.GetJobs()[0].GetError(), "did not answer")
174+
175+ // The runner's late CompleteJob returns the server's result.
176+ res, err := api.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
177+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED,
178+ }))
179+ require.NoError(t, err)
180+ assert.False(t, res.Msg.GetApplied())
181+
182+ // A completed run cannot be cancelled.
183+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
184+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
185+}
186+
187+// A running job without heartbeats fails with GitHub's lost-runner message.
188+func TestCILostRunnerFailsTheJob(t *testing.T) {
189+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
190+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
191+ })
192+ runner := c.registerRunner("alpha", "ubuntu-latest")
193+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
194+ job := runner.await()
195+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
196+ require.NoError(t, err)
197+
198+ c.waitFor("the job is lost", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
199+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
200+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
201+ assert.Contains(t, j.GetError(), "The self-hosted runner: alpha lost communication with the server")
202+}
203+
204+// AC6: a re-run is a new attempt of the same run; the failed-only variant keeps
205+// the jobs that passed.
206+func TestCIRerun(t *testing.T) {
207+ c := newCIHarness(t, nil)
208+ runner := c.registerRunner("alpha", "ubuntu-latest")
209+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
210+ run := func() *pb.Run { return c.runs()[0] }
211+
212+ build := runner.await()
213+ runner.runJob(build, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
214+ test := runner.await()
215+ runner.runJob(test, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "red")
216+ c.waitFor("the run fails", func() bool { return run().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
217+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run().GetConclusion())
218+ id, number := run().GetId(), run().GetNumber()
219+
220+ // A running run cannot be re-run; a completed one can, by who has ci:write.
221+ reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
222+ _, err := reader.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
223+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
224+
225+ res, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
226+ require.NoError(t, err)
227+ assert.Equal(t, id, res.Msg.GetRun().GetId())
228+ assert.Equal(t, number, res.Msg.GetRun().GetNumber())
229+ assert.EqualValues(t, 2, res.Msg.GetRun().GetAttempt())
230+
231+ // build keeps its attempt-1 result; only test runs again.
232+ again := runner.await()
233+ assert.Equal(t, "test", again.GetJobKey())
234+ assert.EqualValues(t, 2, again.GetAttempt())
235+ runner.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "green")
236+ c.waitFor("attempt 2 passes", func() bool {
237+ r := run()
238+ return r.GetAttempt() == 2 && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
239+ })
240+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, run().GetConclusion())
241+ detail := c.getRun(id)
242+ require.Len(t, detail.GetJobs(), 2)
243+ assert.EqualValues(t, 1, detail.GetJobs()[0].GetAttempt(), "build was kept from attempt 1")
244+ assert.EqualValues(t, 2, detail.GetJobs()[1].GetAttempt())
245+
246+ // Attempt 1 is still readable, with its failure.
247+ first, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: id, Attempt: 1}))
248+ require.NoError(t, err)
249+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, first.Msg.GetRun().GetConclusion())
250+ assert.EqualValues(t, 2, first.Msg.GetLatestAttempt())
251+
252+ // Re-running a run with nothing failed is refused with failed-only.
253+ _, err = c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
254+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
255+}
256+
257+// A matrix is expanded when its job is ready, with fail-fast.
258+func TestCIMatrixAndFailFast(t *testing.T) {
259+ c := newCIHarness(t, nil)
260+ runner := c.registerRunner("alpha", "ubuntu-latest")
261+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
262+jobs:
263+ build:
264+ runs-on: ubuntu-latest
265+ strategy:
266+ matrix:
267+ go: ['1.26', '1.27']
268+ steps:
269+ - run: echo ${{ matrix.go }}
270+ after:
271+ needs: build
272+ if: always()
273+ runs-on: ubuntu-latest
274+ steps:
275+ - run: echo done
276+`}, "")
277+ a := runner.await()
278+ b := runner.await()
279+ assert.Equal(t, "build", a.GetJobKey())
280+ assert.JSONEq(t, `{"go":"1.26"}`, a.GetMatrix())
281+ assert.JSONEq(t, `{"go":"1.27"}`, b.GetMatrix())
282+ assert.Contains(t, a.GetContexts(), `"job-total":2`)
283+
284+ jobs := c.getRun(c.runs()[0].GetId()).GetJobs()
285+ var names []string
286+ for _, j := range jobs {
287+ names = append(names, j.GetName())
288+ }
289+ assert.Equal(t, []string{"build (1.26)", "build (1.27)", "after"}, names)
290+
291+ // The first fails: fail-fast asks the second to stop.
292+ apiB := runner.jobAPI(b)
293+ _, err := apiB.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: b.GetJobId()}))
294+ require.NoError(t, err)
295+ watch, err := apiB.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: b.GetJobId()}))
296+ require.NoError(t, err)
297+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
298+ var reason pb.CancelReason
299+ for watch.Receive() {
300+ if m := watch.Msg().GetCancel(); m != nil {
301+ reason = m.GetReason()
302+ break
303+ }
304+ }
305+ assert.Equal(t, pb.CancelReason_CANCEL_REASON_FAIL_FAST, reason)
306+ _, err = apiB.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED}))
307+ require.NoError(t, err)
308+
309+ // "after" has if: always(), so it runs once both ended.
310+ after := runner.await()
311+ assert.Equal(t, "after", after.GetJobKey())
312+ assert.Contains(t, after.GetContexts(), `"result":"failure"`)
313+ runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "done")
314+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
315+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion())
316+}
317+
318+// A job with a feature of a later phase fails clearly, never silently.
319+func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) {
320+ c := newCIHarness(t, nil)
321+ c.registerRunner("alpha", "ubuntu-latest")
322+ c.push("main", map[string]string{".github/workflows/u.yml": `on: push
323+jobs:
324+ j:
325+ runs-on: ubuntu-latest
326+ steps:
327+ - uses: actions/checkout@v4
328+`}, "")
329+ c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
330+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
331+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
332+ assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError())
333+}
334+
335+// A job for a label no runner has is "waiting for a runner" and fails after the
336+// queue timeout.
337+func TestCIQueueTimeout(t *testing.T) {
338+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.QueueTimeout, cfg.Tick = 300*time.Millisecond, 20*time.Millisecond })
339+ c.registerRunner("alpha", "ubuntu-latest")
340+ c.push("main", map[string]string{".github/workflows/q.yml": strings.Replace(workflowWith("branches: [main]"), "ubuntu-latest", "gpu", 1)}, "")
341+ c.waitFor("the job fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
342+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
343+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
344+ assert.Contains(t, j.GetError(), "was not acquired")
345+}
346+
347+// The runner API validates what it is given.
348+func TestCIRunnerRegistrationRules(t *testing.T) {
349+ c := newCIHarness(t, nil)
350+ reg := func(api *clients, name string, replace bool) error {
351+ _, err := api.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
352+ Name: name, Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: replace,
353+ }))
354+ return err
355+ }
356+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(reg(c.api(""), "a", false)))
357+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRepoRead))), "a", false)))
358+ 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")
359+ reg1 := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
360+ require.NoError(t, reg(reg1, "a", false))
361+ assert.Equal(t, connect.CodeAlreadyExists, ciErrCode(reg(reg1, "a", false)))
362+ require.NoError(t, reg(reg1, "a", true), "replace")
363+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(reg(reg1, "bad name!", false)))
364+
365+ list, err := reg1.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
366+ require.NoError(t, err)
367+ require.Len(t, list.Msg.GetRunners(), 1)
368+ assert.Equal(t, []string{"linux", "self-hosted", "x64"}, list.Msg.GetRunners()[0].GetLabels(), "implicit labels, sorted")
369+}
370+
371+// AC2: after a restart of the server, running jobs go on with their tokens,
372+// queued jobs stay queued, and a job whose runner kept quiet during the outage
373+// is not failed for it.
374+func TestCIRestartKeepsRunningAndQueuedJobs(t *testing.T) {
375+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.HeartbeatDeadline = 2 * time.Second })
376+ runner := c.registerRunner("alpha", "ubuntu-latest")
377+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
378+ job := runner.await() // "build"; "test" waits for it
379+ api := runner.jobAPI(job)
380+ _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
381+ require.NoError(t, err)
382+ _, err = api.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
383+ JobId: job.GetJobId(), Step: 2, Offset: 0, Lines: []*pb.LogLine{{Time: now(), Text: "before the restart"}},
384+ }))
385+ require.NoError(t, err)
386+
387+ // Stop the server and start another on the same data directory.
388+ cfg, hookExe := c.forge.cfg, binary(t)
389+ c.srv.Close()
390+ c.forge.Close(c.ctx)
391+ f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, ci: func(cc *usecase.CIConfig) { cc.HeartbeatDeadline = 2 * time.Second }})
392+ require.NoError(t, err)
393+ t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
394+ srv := httptest.NewServer(f2.server.Handler())
395+ t.Cleanup(srv.Close)
396+ api2 := newClients(srv.URL+"/-/api", job.GetJobToken())
397+
398+ // The job token still works, and the log goes on from where it was.
399+ _, err = api2.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
400+ require.NoError(t, err, "the job token survives a restart")
401+ next, err := api2.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
402+ JobId: job.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "after the restart"}},
403+ }))
404+ require.NoError(t, err)
405+ assert.EqualValues(t, 2, next.Msg.GetNextOffset())
406+
407+ root2 := newClients(srv.URL+"/-/api", adminToken)
408+ runs, err := root2.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
409+ require.NoError(t, err)
410+ require.Len(t, runs.Msg.GetRuns(), 1)
411+ assert.Equal(t, pb.CiStatus_CI_STATUS_IN_PROGRESS, runs.Msg.GetRuns()[0].GetStatus())
412+
413+ // Finish the job on the new server: its dependant is planned and ready.
414+ _, err = api2.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
415+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
416+ }))
417+ require.NoError(t, err)
418+ got := make(chan *pb.Job, 1)
419+ go func() {
420+ for range 50 {
421+ res, err := newClients(srv.URL+"/-/api", c.runnerSecret(runner)).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{}))
422+ if err == nil && res.Msg.GetJob() != nil {
423+ got <- res.Msg.GetJob()
424+ return
425+ }
426+ }
427+ got <- nil
428+ }()
429+ select {
430+ case j := <-got:
431+ require.NotNil(t, j, "the runner token and the queue survive a restart")
432+ assert.Equal(t, "test", j.GetJobKey())
433+ case <-time.After(30 * time.Second):
434+ t.Fatal("no job after the restart")
435+ }
436+}
437+
438+// AC5: a head without any check merges, and so does one whose checks passed; a
439+// merge runs the push workflows on the target.
440+func TestCIMergeWithoutChecksAndWithPassingChecks(t *testing.T) {
441+ c := newCIHarness(t, nil)
442+ runner := c.registerRunner("alpha", "ubuntu-latest")
443+ c.push("main", map[string]string{"README": "x\n"}, "")
444+ headOf := func(branch string) string { return strings.TrimSpace(c.bareGit("proj", "rev-parse", branch)) }
445+ merge := func(n int64, head string) error {
446+ _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
447+ return err
448+ }
449+
450+ // No workflow on the branch: no check, the merge goes through.
451+ c.push("plain", map[string]string{"a.txt": "a\n"}, "")
452+ mr1, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "plain", SourceBranch: "plain"}))
453+ require.NoError(t, err)
454+ require.NoError(t, merge(mr1.Msg.GetMergeRequest().GetNumber(), headOf("plain")))
455+
456+ // A branch with a workflow: merge after its check passed. The merge itself
457+ // triggers the workflow on main (GitHub runs push workflows on a merge).
458+ c.mustGit(c.wt, "checkout", "-q", "main")
459+ c.mustGit(c.wt, "pull", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
460+ c.mustGit(c.wt, "checkout", "-q", "-b", "ci")
461+ c.writeFile(c.wt, ".github/workflows/ci.yml", []byte(strings.Replace(workflowWith("branches: [main, ci]"), "echo hi", "echo ci", 1)))
462+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "ci")
463+ head := headOf("ci")
464+ c.waitFor("the run on ci", func() bool { return len(c.checks(head)) == 1 })
465+ mr2, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "ci", SourceBranch: "ci"}))
466+ require.NoError(t, err)
467+ job := runner.await()
468+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
469+ c.waitFor("the check passes", func() bool {
470+ cs := c.checks(head)
471+ return len(cs) == 1 && cs[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_SUCCESS
472+ })
473+ require.NoError(t, merge(mr2.Msg.GetMergeRequest().GetNumber(), head))
474+
475+ // The fast-forward merge moved main to the same commit: the workflow runs
476+ // again for main, with the owner as the actor.
477+ c.waitFor("the merge triggers a run on main", func() bool {
478+ for _, r := range c.runs() {
479+ if r.GetRef() == "refs/heads/main" && r.GetSha() == head {
480+ return true
481+ }
482+ }
483+ return false
484+ })
485+ for _, r := range c.runs() {
486+ if r.GetRef() == "refs/heads/main" {
487+ assert.Equal(t, "owner", r.GetActor())
488+ }
489+ }
490+}
491+
492+// Repository deletion removes the runs, the checks and the logs; a repository
493+// created again starts at run number 1.
494+func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
495+ c := newCIHarness(t, nil)
496+ runner := c.registerRunner("alpha", "ubuntu-latest")
497+ sha := c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
498+ job := runner.await()
499+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "line")
500+ logDir := filepath.Join(c.data, "ci", "logs")
501+ entries, err := os.ReadDir(logDir)
502+ require.NoError(t, err)
503+ require.NotEmpty(t, entries, "the log is a file under ci/logs")
504+
505+ _, err = c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
506+ require.NoError(t, err)
507+ _, err = c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
508+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
509+ entries, err = os.ReadDir(logDir)
510+ require.NoError(t, err)
511+ assert.Empty(t, entries, "the logs are removed with the repository")
512+
513+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("proj"), Visibility: domain.VisibilityPrivate})
514+ require.NoError(t, err)
515+ assert.Empty(t, c.checks(sha), "no check survives the repository")
516+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0] + "# again\n"}, "again")
517+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
518+ assert.EqualValues(t, 1, c.runs()[0].GetNumber(), "run numbers start again with the repository")
519+ assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
520+}
521+
522+// The events of a run and its jobs are published on the repository topic.
523+func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
524+ c := newCIHarness(t, nil)
525+ runner := c.registerRunner("alpha", "ubuntu-latest")
526+ s := c.watch(c.root, "", "repo:proj")
527+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
528+ job := runner.await()
529+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
530+
531+ var runUpdates, jobUpdates int
532+ var lastRun *pb.Run
533+ deadline := time.After(10 * time.Second)
534+ for lastRun.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
535+ select {
536+ case ev := <-s.events:
537+ if r := ev.GetRunUpdated(); r != nil {
538+ runUpdates++
539+ if r.GetRun().GetVersion() >= lastRun.GetVersion() {
540+ lastRun = r.GetRun()
541+ }
542+ }
543+ if ev.GetJobUpdated() != nil {
544+ jobUpdates++
545+ }
546+ case <-deadline:
547+ t.Fatalf("no completed run event: %d run and %d job events", runUpdates, jobUpdates)
548+ }
549+ }
550+ assert.GreaterOrEqual(t, runUpdates, 2)
551+ assert.GreaterOrEqual(t, jobUpdates, 2)
552+
553+ // They are never sent on "home".
554+ home := c.watch(c.root, "", "home")
555+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
556+ require.NoError(t, err)
557+ c.sentinel(t, home, "sentinel")
558+}
@@ -0,0 +1,558 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "fmt"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+const oneJob = `on:
26+ push:
27+ %s
28+jobs:
29+ j:
30+ runs-on: ubuntu-latest
31+ steps:
32+ - run: echo hi
33+`
34+
35+func workflowWith(filter string) string { return fmt.Sprintf(oneJob, filter) }
36+
37+// AC3: filters, tags, skip directives, invalid files and deletions.
38+func TestCITriggerRules(t *testing.T) {
39+ c := newCIHarness(t, nil)
40+ c.registerRunner("alpha", "ubuntu-latest")
41+ count := func() int { return len(c.runs()) }
42+ settle := func() { time.Sleep(300 * time.Millisecond) } // a push that runs nothing leaves nothing to wait for
43+
44+ // A workflow with a branch filter does not run for another branch.
45+ c.push("main", map[string]string{".github/workflows/only-main.yml": workflowWith("branches: [main]"), "src/a.go": "package a\n"}, "")
46+ c.waitFor("main runs", func() bool { return count() == 1 })
47+ c.push("other", map[string]string{"x.txt": "x\n"}, "")
48+ settle()
49+ assert.Equal(t, 1, count(), "branches: [main] does not run for other")
50+
51+ // A paths filter looks at the files of the push.
52+ c.push("main", map[string]string{".github/workflows/paths.yml": workflowWith("paths: ['docs/**']")}, "")
53+ c.waitFor("only-main runs again for the new commit", func() bool { return count() == 2 })
54+ c.push("main", map[string]string{"docs/readme.md": "doc\n"}, "")
55+ c.waitFor("both run for docs", func() bool { return count() == 4 })
56+ c.push("main", map[string]string{"src/b.go": "package b\n"}, "")
57+ c.waitFor("only-main runs for src", func() bool { return count() == 5 })
58+ settle()
59+ assert.Equal(t, 5, count(), "the paths filter kept paths.yml out")
60+
61+ // [skip ci] on the head commit.
62+ c.push("main", map[string]string{"src/c.go": "package c\n"}, "wip [skip ci]")
63+ settle()
64+ assert.Equal(t, 5, count())
65+
66+ // An invalid workflow file is a failed run without a job.
67+ c.push("main", map[string]string{".github/workflows/broken.yml": "on: push\njobs:\n j:\n steps: [\n"}, "")
68+ c.waitFor("the invalid file gives a run", func() bool {
69+ for _, r := range c.runs() {
70+ if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
71+ return true
72+ }
73+ }
74+ return false
75+ })
76+ var broken *pb.Run
77+ for _, r := range c.runs() {
78+ if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
79+ broken = r
80+ }
81+ }
82+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, broken.GetStatus())
83+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, broken.GetConclusion())
84+ assert.NotEmpty(t, broken.GetError())
85+ assert.Empty(t, c.getRun(broken.GetId()).GetJobs(), "an invalid file has no job")
86+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
87+ var failed []string
88+ for _, ck := range c.checks(sha) {
89+ if ck.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE {
90+ failed = append(failed, ck.GetName())
91+ }
92+ }
93+ assert.Equal(t, []string{".github/workflows/broken.yml"}, failed, "and a failed check on the commit")
94+
95+ // A push never fails for CI: the push above succeeded (mustGit). A deletion
96+ // runs nothing.
97+ before := count()
98+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":other")
99+ settle()
100+ assert.Equal(t, before, count())
101+}
102+
103+// AC3: tags run tag-filtered workflows; more than 3 tags in one push run none.
104+func TestCITags(t *testing.T) {
105+ c := newCIHarness(t, nil)
106+ c.registerRunner("alpha", "ubuntu-latest")
107+ c.push("main", map[string]string{".github/workflows/rel.yml": workflowWith("tags: ['v*']")}, "")
108+ settle := func() { time.Sleep(300 * time.Millisecond) }
109+ settle()
110+ assert.Empty(t, c.runs(), "a tags-only workflow does not run for a branch")
111+
112+ c.mustGit(c.wt, "tag", "v1")
113+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
114+ c.waitFor("a tag runs", func() bool { return len(c.runs()) == 1 })
115+ assert.Equal(t, "refs/tags/v1", c.runs()[0].GetRef())
116+
117+ for _, tag := range []string{"v2", "v3", "v4", "v5"} {
118+ c.mustGit(c.wt, "tag", tag)
119+ }
120+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v2", "v3", "v4", "v5")
121+ settle()
122+ assert.Len(t, c.runs(), 1, "more than 3 tags in one push run nothing")
123+}
124+
125+// AC6: cancelling a run asks the runner to stop through WatchJob; a runner that
126+// does not answer is ended by the server.
127+func TestCICancelReachesTheRunnerAndIsEnforced(t *testing.T) {
128+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
129+ cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 400*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
130+ })
131+ runner := c.registerRunner("alpha", "ubuntu-latest")
132+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
133+ job := runner.await()
134+ api := runner.jobAPI(job)
135+ _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
136+ require.NoError(t, err)
137+
138+ watch, err := api.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: job.GetJobId()}))
139+ require.NoError(t, err)
140+ run := c.runs()[0]
141+
142+ // A reader without ci:write cannot cancel.
143+ reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
144+ _, err = reader.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
145+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
146+
147+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
148+ require.NoError(t, err)
149+ // Cancelling again changes nothing.
150+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
151+ require.NoError(t, err)
152+
153+ var cancel *pb.CancelRequest
154+ for watch.Receive() {
155+ if m := watch.Msg().GetCancel(); m != nil {
156+ cancel = m
157+ break
158+ }
159+ }
160+ require.NotNil(t, cancel, "the runner is told")
161+ assert.Equal(t, pb.CancelReason_CANCEL_REASON_USER, cancel.GetReason())
162+
163+ // The queued job that needs it never ran: cancelled at once. The running
164+ // one is ended by the server after the grace period.
165+ c.waitFor("the server ends the job", func() bool {
166+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
167+ })
168+ detail := c.getRun(run.GetId())
169+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, detail.GetRun().GetConclusion())
170+ for _, j := range detail.GetJobs() {
171+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion(), j.GetKey())
172+ }
173+ assert.Contains(t, detail.GetJobs()[0].GetError(), "did not answer")
174+
175+ // The runner's late CompleteJob returns the server's result.
176+ res, err := api.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
177+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED,
178+ }))
179+ require.NoError(t, err)
180+ assert.False(t, res.Msg.GetApplied())
181+
182+ // A completed run cannot be cancelled.
183+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
184+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
185+}
186+
187+// A running job without heartbeats fails with GitHub's lost-runner message.
188+func TestCILostRunnerFailsTheJob(t *testing.T) {
189+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
190+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
191+ })
192+ runner := c.registerRunner("alpha", "ubuntu-latest")
193+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
194+ job := runner.await()
195+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
196+ require.NoError(t, err)
197+
198+ c.waitFor("the job is lost", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
199+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
200+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
201+ assert.Contains(t, j.GetError(), "The self-hosted runner: alpha lost communication with the server")
202+}
203+
204+// AC6: a re-run is a new attempt of the same run; the failed-only variant keeps
205+// the jobs that passed.
206+func TestCIRerun(t *testing.T) {
207+ c := newCIHarness(t, nil)
208+ runner := c.registerRunner("alpha", "ubuntu-latest")
209+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
210+ run := func() *pb.Run { return c.runs()[0] }
211+
212+ build := runner.await()
213+ runner.runJob(build, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
214+ test := runner.await()
215+ runner.runJob(test, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "red")
216+ c.waitFor("the run fails", func() bool { return run().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
217+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run().GetConclusion())
218+ id, number := run().GetId(), run().GetNumber()
219+
220+ // A running run cannot be re-run; a completed one can, by who has ci:write.
221+ reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
222+ _, err := reader.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
223+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
224+
225+ res, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
226+ require.NoError(t, err)
227+ assert.Equal(t, id, res.Msg.GetRun().GetId())
228+ assert.Equal(t, number, res.Msg.GetRun().GetNumber())
229+ assert.EqualValues(t, 2, res.Msg.GetRun().GetAttempt())
230+
231+ // build keeps its attempt-1 result; only test runs again.
232+ again := runner.await()
233+ assert.Equal(t, "test", again.GetJobKey())
234+ assert.EqualValues(t, 2, again.GetAttempt())
235+ runner.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "green")
236+ c.waitFor("attempt 2 passes", func() bool {
237+ r := run()
238+ return r.GetAttempt() == 2 && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
239+ })
240+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, run().GetConclusion())
241+ detail := c.getRun(id)
242+ require.Len(t, detail.GetJobs(), 2)
243+ assert.EqualValues(t, 1, detail.GetJobs()[0].GetAttempt(), "build was kept from attempt 1")
244+ assert.EqualValues(t, 2, detail.GetJobs()[1].GetAttempt())
245+
246+ // Attempt 1 is still readable, with its failure.
247+ first, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: id, Attempt: 1}))
248+ require.NoError(t, err)
249+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, first.Msg.GetRun().GetConclusion())
250+ assert.EqualValues(t, 2, first.Msg.GetLatestAttempt())
251+
252+ // Re-running a run with nothing failed is refused with failed-only.
253+ _, err = c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
254+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
255+}
256+
257+// A matrix is expanded when its job is ready, with fail-fast.
258+func TestCIMatrixAndFailFast(t *testing.T) {
259+ c := newCIHarness(t, nil)
260+ runner := c.registerRunner("alpha", "ubuntu-latest")
261+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
262+jobs:
263+ build:
264+ runs-on: ubuntu-latest
265+ strategy:
266+ matrix:
267+ go: ['1.26', '1.27']
268+ steps:
269+ - run: echo ${{ matrix.go }}
270+ after:
271+ needs: build
272+ if: always()
273+ runs-on: ubuntu-latest
274+ steps:
275+ - run: echo done
276+`}, "")
277+ a := runner.await()
278+ b := runner.await()
279+ assert.Equal(t, "build", a.GetJobKey())
280+ assert.JSONEq(t, `{"go":"1.26"}`, a.GetMatrix())
281+ assert.JSONEq(t, `{"go":"1.27"}`, b.GetMatrix())
282+ assert.Contains(t, a.GetContexts(), `"job-total":2`)
283+
284+ jobs := c.getRun(c.runs()[0].GetId()).GetJobs()
285+ var names []string
286+ for _, j := range jobs {
287+ names = append(names, j.GetName())
288+ }
289+ assert.Equal(t, []string{"build (1.26)", "build (1.27)", "after"}, names)
290+
291+ // The first fails: fail-fast asks the second to stop.
292+ apiB := runner.jobAPI(b)
293+ _, err := apiB.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: b.GetJobId()}))
294+ require.NoError(t, err)
295+ watch, err := apiB.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: b.GetJobId()}))
296+ require.NoError(t, err)
297+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
298+ var reason pb.CancelReason
299+ for watch.Receive() {
300+ if m := watch.Msg().GetCancel(); m != nil {
301+ reason = m.GetReason()
302+ break
303+ }
304+ }
305+ assert.Equal(t, pb.CancelReason_CANCEL_REASON_FAIL_FAST, reason)
306+ _, err = apiB.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED}))
307+ require.NoError(t, err)
308+
309+ // "after" has if: always(), so it runs once both ended.
310+ after := runner.await()
311+ assert.Equal(t, "after", after.GetJobKey())
312+ assert.Contains(t, after.GetContexts(), `"result":"failure"`)
313+ runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "done")
314+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
315+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion())
316+}
317+
318+// A job with a feature of a later phase fails clearly, never silently.
319+func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) {
320+ c := newCIHarness(t, nil)
321+ c.registerRunner("alpha", "ubuntu-latest")
322+ c.push("main", map[string]string{".github/workflows/u.yml": `on: push
323+jobs:
324+ j:
325+ runs-on: ubuntu-latest
326+ steps:
327+ - uses: actions/checkout@v4
328+`}, "")
329+ c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
330+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
331+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
332+ assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError())
333+}
334+
335+// A job for a label no runner has is "waiting for a runner" and fails after the
336+// queue timeout.
337+func TestCIQueueTimeout(t *testing.T) {
338+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.QueueTimeout, cfg.Tick = 300*time.Millisecond, 20*time.Millisecond })
339+ c.registerRunner("alpha", "ubuntu-latest")
340+ c.push("main", map[string]string{".github/workflows/q.yml": strings.Replace(workflowWith("branches: [main]"), "ubuntu-latest", "gpu", 1)}, "")
341+ c.waitFor("the job fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
342+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
343+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
344+ assert.Contains(t, j.GetError(), "was not acquired")
345+}
346+
347+// The runner API validates what it is given.
348+func TestCIRunnerRegistrationRules(t *testing.T) {
349+ c := newCIHarness(t, nil)
350+ reg := func(api *clients, name string, replace bool) error {
351+ _, err := api.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
352+ Name: name, Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: replace,
353+ }))
354+ return err
355+ }
356+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(reg(c.api(""), "a", false)))
357+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRepoRead))), "a", false)))
358+ 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")
359+ reg1 := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
360+ require.NoError(t, reg(reg1, "a", false))
361+ assert.Equal(t, connect.CodeAlreadyExists, ciErrCode(reg(reg1, "a", false)))
362+ require.NoError(t, reg(reg1, "a", true), "replace")
363+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(reg(reg1, "bad name!", false)))
364+
365+ list, err := reg1.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
366+ require.NoError(t, err)
367+ require.Len(t, list.Msg.GetRunners(), 1)
368+ assert.Equal(t, []string{"linux", "self-hosted", "x64"}, list.Msg.GetRunners()[0].GetLabels(), "implicit labels, sorted")
369+}
370+
371+// AC2: after a restart of the server, running jobs go on with their tokens,
372+// queued jobs stay queued, and a job whose runner kept quiet during the outage
373+// is not failed for it.
374+func TestCIRestartKeepsRunningAndQueuedJobs(t *testing.T) {
375+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.HeartbeatDeadline = 2 * time.Second })
376+ runner := c.registerRunner("alpha", "ubuntu-latest")
377+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
378+ job := runner.await() // "build"; "test" waits for it
379+ api := runner.jobAPI(job)
380+ _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
381+ require.NoError(t, err)
382+ _, err = api.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
383+ JobId: job.GetJobId(), Step: 2, Offset: 0, Lines: []*pb.LogLine{{Time: now(), Text: "before the restart"}},
384+ }))
385+ require.NoError(t, err)
386+
387+ // Stop the server and start another on the same data directory.
388+ cfg, hookExe := c.forge.cfg, binary(t)
389+ c.srv.Close()
390+ c.forge.Close(c.ctx)
391+ f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, ci: func(cc *usecase.CIConfig) { cc.HeartbeatDeadline = 2 * time.Second }})
392+ require.NoError(t, err)
393+ t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
394+ srv := httptest.NewServer(f2.server.Handler())
395+ t.Cleanup(srv.Close)
396+ api2 := newClients(srv.URL+"/-/api", job.GetJobToken())
397+
398+ // The job token still works, and the log goes on from where it was.
399+ _, err = api2.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
400+ require.NoError(t, err, "the job token survives a restart")
401+ next, err := api2.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
402+ JobId: job.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "after the restart"}},
403+ }))
404+ require.NoError(t, err)
405+ assert.EqualValues(t, 2, next.Msg.GetNextOffset())
406+
407+ root2 := newClients(srv.URL+"/-/api", adminToken)
408+ runs, err := root2.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
409+ require.NoError(t, err)
410+ require.Len(t, runs.Msg.GetRuns(), 1)
411+ assert.Equal(t, pb.CiStatus_CI_STATUS_IN_PROGRESS, runs.Msg.GetRuns()[0].GetStatus())
412+
413+ // Finish the job on the new server: its dependant is planned and ready.
414+ _, err = api2.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
415+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
416+ }))
417+ require.NoError(t, err)
418+ got := make(chan *pb.Job, 1)
419+ go func() {
420+ for range 50 {
421+ res, err := newClients(srv.URL+"/-/api", c.runnerSecret(runner)).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{}))
422+ if err == nil && res.Msg.GetJob() != nil {
423+ got <- res.Msg.GetJob()
424+ return
425+ }
426+ }
427+ got <- nil
428+ }()
429+ select {
430+ case j := <-got:
431+ require.NotNil(t, j, "the runner token and the queue survive a restart")
432+ assert.Equal(t, "test", j.GetJobKey())
433+ case <-time.After(30 * time.Second):
434+ t.Fatal("no job after the restart")
435+ }
436+}
437+
438+// AC5: a head without any check merges, and so does one whose checks passed; a
439+// merge runs the push workflows on the target.
440+func TestCIMergeWithoutChecksAndWithPassingChecks(t *testing.T) {
441+ c := newCIHarness(t, nil)
442+ runner := c.registerRunner("alpha", "ubuntu-latest")
443+ c.push("main", map[string]string{"README": "x\n"}, "")
444+ headOf := func(branch string) string { return strings.TrimSpace(c.bareGit("proj", "rev-parse", branch)) }
445+ merge := func(n int64, head string) error {
446+ _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
447+ return err
448+ }
449+
450+ // No workflow on the branch: no check, the merge goes through.
451+ c.push("plain", map[string]string{"a.txt": "a\n"}, "")
452+ mr1, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "plain", SourceBranch: "plain"}))
453+ require.NoError(t, err)
454+ require.NoError(t, merge(mr1.Msg.GetMergeRequest().GetNumber(), headOf("plain")))
455+
456+ // A branch with a workflow: merge after its check passed. The merge itself
457+ // triggers the workflow on main (GitHub runs push workflows on a merge).
458+ c.mustGit(c.wt, "checkout", "-q", "main")
459+ c.mustGit(c.wt, "pull", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
460+ c.mustGit(c.wt, "checkout", "-q", "-b", "ci")
461+ c.writeFile(c.wt, ".github/workflows/ci.yml", []byte(strings.Replace(workflowWith("branches: [main, ci]"), "echo hi", "echo ci", 1)))
462+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "ci")
463+ head := headOf("ci")
464+ c.waitFor("the run on ci", func() bool { return len(c.checks(head)) == 1 })
465+ mr2, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "ci", SourceBranch: "ci"}))
466+ require.NoError(t, err)
467+ job := runner.await()
468+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
469+ c.waitFor("the check passes", func() bool {
470+ cs := c.checks(head)
471+ return len(cs) == 1 && cs[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_SUCCESS
472+ })
473+ require.NoError(t, merge(mr2.Msg.GetMergeRequest().GetNumber(), head))
474+
475+ // The fast-forward merge moved main to the same commit: the workflow runs
476+ // again for main, with the owner as the actor.
477+ c.waitFor("the merge triggers a run on main", func() bool {
478+ for _, r := range c.runs() {
479+ if r.GetRef() == "refs/heads/main" && r.GetSha() == head {
480+ return true
481+ }
482+ }
483+ return false
484+ })
485+ for _, r := range c.runs() {
486+ if r.GetRef() == "refs/heads/main" {
487+ assert.Equal(t, "owner", r.GetActor())
488+ }
489+ }
490+}
491+
492+// Repository deletion removes the runs, the checks and the logs; a repository
493+// created again starts at run number 1.
494+func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
495+ c := newCIHarness(t, nil)
496+ runner := c.registerRunner("alpha", "ubuntu-latest")
497+ sha := c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
498+ job := runner.await()
499+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "line")
500+ logDir := filepath.Join(c.data, "ci", "logs")
501+ entries, err := os.ReadDir(logDir)
502+ require.NoError(t, err)
503+ require.NotEmpty(t, entries, "the log is a file under ci/logs")
504+
505+ _, err = c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
506+ require.NoError(t, err)
507+ _, err = c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
508+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
509+ entries, err = os.ReadDir(logDir)
510+ require.NoError(t, err)
511+ assert.Empty(t, entries, "the logs are removed with the repository")
512+
513+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("proj"), Visibility: domain.VisibilityPrivate})
514+ require.NoError(t, err)
515+ assert.Empty(t, c.checks(sha), "no check survives the repository")
516+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0] + "# again\n"}, "again")
517+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
518+ assert.EqualValues(t, 1, c.runs()[0].GetNumber(), "run numbers start again with the repository")
519+ assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
520+}
521+
522+// The events of a run and its jobs are published on the repository topic.
523+func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
524+ c := newCIHarness(t, nil)
525+ runner := c.registerRunner("alpha", "ubuntu-latest")
526+ s := c.watch(c.root, "", "repo:proj")
527+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
528+ job := runner.await()
529+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
530+
531+ var runUpdates, jobUpdates int
532+ var lastRun *pb.Run
533+ deadline := time.After(10 * time.Second)
534+ for lastRun.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
535+ select {
536+ case ev := <-s.events:
537+ if r := ev.GetRunUpdated(); r != nil {
538+ runUpdates++
539+ if r.GetRun().GetVersion() >= lastRun.GetVersion() {
540+ lastRun = r.GetRun()
541+ }
542+ }
543+ if ev.GetJobUpdated() != nil {
544+ jobUpdates++
545+ }
546+ case <-deadline:
547+ t.Fatalf("no completed run event: %d run and %d job events", runUpdates, jobUpdates)
548+ }
549+ }
550+ assert.GreaterOrEqual(t, runUpdates, 2)
551+ assert.GreaterOrEqual(t, jobUpdates, 2)
552+
553+ // They are never sent on "home".
554+ home := c.watch(c.root, "", "home")
555+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
556+ require.NoError(t, err)
557+ c.sentinel(t, home, "sentinel")
558+}
M internal/ports/ci.go +9 -1
@@ -280,7 +280,10 @@ type RunnerService interface {
280280 UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error
281281 AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error)
282282 Heartbeat(ctx context.Context, secret, jobID string) error
283- WatchJob(ctx context.Context, secret, jobID string, send func(JobSignal) error) error
283+ // WatchJob calls ready once the job token is valid and the stream is
284+ // registered, before any message: the adapter flushes the response headers
285+ // then, because a client's call returns only when they arrive.
286+ WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error
284287 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)
285288 }
286289
@@ -340,6 +343,11 @@ type GetJobLogParams struct {
340343 Attempt int32
341344 Offset int64
342345 Follow bool
346+ // Ready, when not nil, is called once the access is checked and the
347+ // stream is counted, before any batch: the adapter flushes the response
348+ // headers then (a follow on a job with no line yet would keep the client's
349+ // call waiting for the first one).
350+ Ready func()
343351 // Client identifies where the call comes from, for the limit of streams
344352 // per client; empty counts against the total only.
345353 Client string
@@ -280,7 +280,10 @@ type RunnerService interface {
280 UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error280 UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error
281 AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error)281 AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error)
282 Heartbeat(ctx context.Context, secret, jobID string) error282 Heartbeat(ctx context.Context, secret, jobID string) error
283- WatchJob(ctx context.Context, secret, jobID string, send func(JobSignal) error) error283+ // WatchJob calls ready once the job token is valid and the stream is
284+ // registered, before any message: the adapter flushes the response headers
285+ // then, because a client's call returns only when they arrive.
286+ WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error
284 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)287 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)
285 }288 }
286 289
@@ -340,6 +343,11 @@ type GetJobLogParams struct {
340 Attempt int32343 Attempt int32
341 Offset int64344 Offset int64
342 Follow bool345 Follow bool
346+ // Ready, when not nil, is called once the access is checked and the
347+ // stream is counted, before any batch: the adapter flushes the response
348+ // headers then (a follow on a job with no line yet would keep the client's
349+ // call waiting for the first one).
350+ Ready func()
343 // Client identifies where the call comes from, for the limit of streams351 // Client identifies where the call comes from, for the limit of streams
344 // per client; empty counts against the total only.352 // per client; empty counts against the total only.
345 Client string353 Client string
M internal/ports/mocks/RunnerService.go +16 -10
@@ -487,16 +487,16 @@ func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Contex
487487 }
488488
489489 // WatchJob provides a mock function for the type RunnerService
490-func (_mock *RunnerService) WatchJob(ctx context.Context, secret string, jobID string, send func(ports.JobSignal) error) error {
491- ret := _mock.Called(ctx, secret, jobID, send)
490+func (_mock *RunnerService) WatchJob(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error {
491+ ret := _mock.Called(ctx, secret, jobID, ready, send)
492492
493493 if len(ret) == 0 {
494494 panic("no return value specified for WatchJob")
495495 }
496496
497497 var r0 error
498- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, func(ports.JobSignal) error) error); ok {
499- r0 = returnFunc(ctx, secret, jobID, send)
498+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, func(), func(ports.JobSignal) error) error); ok {
499+ r0 = returnFunc(ctx, secret, jobID, ready, send)
500500 } else {
501501 r0 = ret.Error(0)
502502 }
@@ -512,12 +512,13 @@ type RunnerService_WatchJob_Call struct {
512512 // - ctx context.Context
513513 // - secret string
514514 // - jobID string
515+// - ready func()
515516 // - send func(ports.JobSignal) error
516-func (_e *RunnerService_Expecter) WatchJob(ctx any, secret any, jobID any, send any) *RunnerService_WatchJob_Call {
517- return &RunnerService_WatchJob_Call{Call: _e.mock.On("WatchJob", ctx, secret, jobID, send)}
517+func (_e *RunnerService_Expecter) WatchJob(ctx any, secret any, jobID any, ready any, send any) *RunnerService_WatchJob_Call {
518+ return &RunnerService_WatchJob_Call{Call: _e.mock.On("WatchJob", ctx, secret, jobID, ready, send)}
518519 }
519520
520-func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret string, jobID string, send func(ports.JobSignal) error)) *RunnerService_WatchJob_Call {
521+func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error)) *RunnerService_WatchJob_Call {
521522 _c.Call.Run(func(args mock.Arguments) {
522523 var arg0 context.Context
523524 if args[0] != nil {
@@ -531,15 +532,20 @@ func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret
531532 if args[2] != nil {
532533 arg2 = args[2].(string)
533534 }
534- var arg3 func(ports.JobSignal) error
535+ var arg3 func()
535536 if args[3] != nil {
536- arg3 = args[3].(func(ports.JobSignal) error)
537+ arg3 = args[3].(func())
538+ }
539+ var arg4 func(ports.JobSignal) error
540+ if args[4] != nil {
541+ arg4 = args[4].(func(ports.JobSignal) error)
537542 }
538543 run(
539544 arg0,
540545 arg1,
541546 arg2,
542547 arg3,
548+ arg4,
543549 )
544550 })
545551 return _c
@@ -550,7 +556,7 @@ func (_c *RunnerService_WatchJob_Call) Return(err error) *RunnerService_WatchJob
550556 return _c
551557 }
552558
553-func (_c *RunnerService_WatchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, send func(ports.JobSignal) error) error) *RunnerService_WatchJob_Call {
559+func (_c *RunnerService_WatchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error) *RunnerService_WatchJob_Call {
554560 _c.Call.Return(run)
555561 return _c
556562 }
@@ -487,16 +487,16 @@ func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Contex
487 }487 }
488 488
489 // WatchJob provides a mock function for the type RunnerService489 // WatchJob provides a mock function for the type RunnerService
490-func (_mock *RunnerService) WatchJob(ctx context.Context, secret string, jobID string, send func(ports.JobSignal) error) error {490+func (_mock *RunnerService) WatchJob(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error {
491- ret := _mock.Called(ctx, secret, jobID, send)491+ ret := _mock.Called(ctx, secret, jobID, ready, send)
492 492
493 if len(ret) == 0 {493 if len(ret) == 0 {
494 panic("no return value specified for WatchJob")494 panic("no return value specified for WatchJob")
495 }495 }
496 496
497 var r0 error497 var r0 error
498- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, func(ports.JobSignal) error) error); ok {498+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, func(), func(ports.JobSignal) error) error); ok {
499- r0 = returnFunc(ctx, secret, jobID, send)499+ r0 = returnFunc(ctx, secret, jobID, ready, send)
500 } else {500 } else {
501 r0 = ret.Error(0)501 r0 = ret.Error(0)
502 }502 }
@@ -512,12 +512,13 @@ type RunnerService_WatchJob_Call struct {
512 // - ctx context.Context512 // - ctx context.Context
513 // - secret string513 // - secret string
514 // - jobID string514 // - jobID string
515+// - ready func()
515 // - send func(ports.JobSignal) error516 // - send func(ports.JobSignal) error
516-func (_e *RunnerService_Expecter) WatchJob(ctx any, secret any, jobID any, send any) *RunnerService_WatchJob_Call {517+func (_e *RunnerService_Expecter) WatchJob(ctx any, secret any, jobID any, ready any, send any) *RunnerService_WatchJob_Call {
517- return &RunnerService_WatchJob_Call{Call: _e.mock.On("WatchJob", ctx, secret, jobID, send)}518+ return &RunnerService_WatchJob_Call{Call: _e.mock.On("WatchJob", ctx, secret, jobID, ready, send)}
518 }519 }
519 520
520-func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret string, jobID string, send func(ports.JobSignal) error)) *RunnerService_WatchJob_Call {521+func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error)) *RunnerService_WatchJob_Call {
521 _c.Call.Run(func(args mock.Arguments) {522 _c.Call.Run(func(args mock.Arguments) {
522 var arg0 context.Context523 var arg0 context.Context
523 if args[0] != nil {524 if args[0] != nil {
@@ -531,15 +532,20 @@ func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret
531 if args[2] != nil {532 if args[2] != nil {
532 arg2 = args[2].(string)533 arg2 = args[2].(string)
533 }534 }
534- var arg3 func(ports.JobSignal) error535+ var arg3 func()
535 if args[3] != nil {536 if args[3] != nil {
536- arg3 = args[3].(func(ports.JobSignal) error)537+ arg3 = args[3].(func())
538+ }
539+ var arg4 func(ports.JobSignal) error
540+ if args[4] != nil {
541+ arg4 = args[4].(func(ports.JobSignal) error)
537 }542 }
538 run(543 run(
539 arg0,544 arg0,
540 arg1,545 arg1,
541 arg2,546 arg2,
542 arg3,547 arg3,
548+ arg4,
543 )549 )
544 })550 })
545 return _c551 return _c
@@ -550,7 +556,7 @@ func (_c *RunnerService_WatchJob_Call) Return(err error) *RunnerService_WatchJob
550 return _c556 return _c
551 }557 }
552 558
553-func (_c *RunnerService_WatchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, send func(ports.JobSignal) error) error) *RunnerService_WatchJob_Call {559+func (_c *RunnerService_WatchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error) *RunnerService_WatchJob_Call {
554 _c.Call.Return(run)560 _c.Call.Return(run)
555 return _c561 return _c
556 }562 }
M internal/usecase/ci_runner.go +4 -1
@@ -623,7 +623,7 @@ func (c *CI) Heartbeat(ctx context.Context, secret, jobID string) error {
623623
624624 // WatchJob streams what the runner must react to: the cancel request (the
625625 // first message when one is already pending), keepalives, and the end.
626-func (c *CI) WatchJob(ctx context.Context, secret, jobID string, send func(ports.JobSignal) error) error {
626+func (c *CI) WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(ports.JobSignal) error) error {
627627 j, err := c.jobFor(ctx, secret, jobID)
628628 if err != nil {
629629 return err
@@ -635,6 +635,9 @@ func (c *CI) WatchJob(ctx context.Context, secret, jobID string, send func(ports
635635 keepalive := time.NewTicker(c.cfg.Keepalive)
636636 defer keepalive.Stop()
637637 cancelSent := false
638+ if ready != nil {
639+ ready()
640+ }
638641 for {
639642 wake := c.jobs.wait(ref)
640643 cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
@@ -623,7 +623,7 @@ func (c *CI) Heartbeat(ctx context.Context, secret, jobID string) error {
623 623
624 // WatchJob streams what the runner must react to: the cancel request (the624 // WatchJob streams what the runner must react to: the cancel request (the
625 // first message when one is already pending), keepalives, and the end.625 // first message when one is already pending), keepalives, and the end.
626-func (c *CI) WatchJob(ctx context.Context, secret, jobID string, send func(ports.JobSignal) error) error {626+func (c *CI) WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(ports.JobSignal) error) error {
627 j, err := c.jobFor(ctx, secret, jobID)627 j, err := c.jobFor(ctx, secret, jobID)
628 if err != nil {628 if err != nil {
629 return err629 return err
@@ -635,6 +635,9 @@ func (c *CI) WatchJob(ctx context.Context, secret, jobID string, send func(ports
635 keepalive := time.NewTicker(c.cfg.Keepalive)635 keepalive := time.NewTicker(c.cfg.Keepalive)
636 defer keepalive.Stop()636 defer keepalive.Stop()
637 cancelSent := false637 cancelSent := false
638+ if ready != nil {
639+ ready()
640+ }
638 for {641 for {
639 wake := c.jobs.wait(ref)642 wake := c.jobs.wait(ref)
640 cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)643 cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
M internal/usecase/ci_runs.go +3 -0
@@ -227,6 +227,9 @@ func (c *CI) GetJobLog(ctx context.Context, p domain.Principal, name domain.Repo
227227 return err
228228 }
229229 defer release()
230+ if params.Ready != nil {
231+ params.Ready()
232+ }
230233 ref := logRef(j)
231234 jref := jobRef(j)
232235 offset := params.Offset
@@ -227,6 +227,9 @@ func (c *CI) GetJobLog(ctx context.Context, p domain.Principal, name domain.Repo
227 return err227 return err
228 }228 }
229 defer release()229 defer release()
230+ if params.Ready != nil {
231+ params.Ready()
232+ }
230 ref := logRef(j)233 ref := logRef(j)
231 jref := jobRef(j)234 jref := jobRef(j)
232 offset := params.Offset235 offset := params.Offset
A internal/usecase/ci_unit_test.go +167 -0
@@ -0,0 +1,167 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+// mergeUnit is a merge request that is ready to merge, with a checks reader.
20+type mergeUnit struct {
21+ *mrUnit
22+ checks *mocks.CIChecksReader
23+ trig *mocks.WorkflowTrigger
24+ head domain.ObjectID
25+}
26+
27+func newMergeUnit(t *testing.T, block bool) *mergeUnit {
28+ t.Helper()
29+ u := &mrUnit{
30+ meta: mocks.NewRepoMetaStore(t),
31+ store: mocks.NewMergeRequestStore(t),
32+ repos: mocks.NewGitRepos(t),
33+ git: mocks.NewMergeGit(t),
34+ repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
35+ }
36+ u.repo.Settings = domain.DefaultRepoSettings()
37+ u.repo.Settings.FailingChecksBlock = block
38+ clock := mocks.NewClock(t)
39+ clock.EXPECT().Now().Return(now).Maybe()
40+ sched := mocks.NewMaintenanceScheduler(t)
41+ sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
42+ m := &mergeUnit{mrUnit: u, checks: mocks.NewCIChecksReader(t), trig: mocks.NewWorkflowTrigger(t), head: someID(t)}
43+ u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44+ Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
45+ Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
46+ CI: m.trig, Checks: m.checks,
47+ })
48+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
49+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{
50+ Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main",
51+ }, nil)
52+ tip, err := domain.ParseObjectID("1111111111111111111111111111111111111111")
53+ require.NoError(t, err)
54+ u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
55+ u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
56+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57+ return m
58+}
59+
60+func (m *mergeUnit) merge() error {
61+ _, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head})
62+ return err
63+}
64+
65+// AC5: the merge rule under FailingChecksBlock.
66+func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
67+ t.Parallel()
68+ check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
69+ return domain.CICheck{Name: name, Status: status, Conclusion: c}
70+ }
71+ tests := []struct {
72+ name string
73+ checks []domain.CICheck
74+ blocked string // part of the message
75+ }{
76+ {"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"},
77+ {"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"},
78+ {"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"},
79+ {"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"},
80+ {"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"},
81+ {"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"},
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ t.Parallel()
86+ m := newMergeUnit(t, true)
87+ m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
88+ // The merge stops at the rule: the branch mock expects no move.
89+ err := m.merge()
90+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
91+ assert.Contains(t, err.Error(), tt.blocked)
92+ })
93+ }
94+ // The cases where the merge goes on (no check, all passing, rule off) need
95+ // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
96+}
97+
98+func TestCombineChecks(t *testing.T) {
99+ t.Parallel()
100+ ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck {
101+ return domain.CICheck{Status: s, Conclusion: c}
102+ }
103+ assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil))
104+ assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)}))
105+ assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")}))
106+ assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending")
107+}
108+
109+func TestLabelsMatchAndRunnerLabels(t *testing.T) {
110+ t.Parallel()
111+ have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64")
112+ assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have)
113+ assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive")
114+ assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have))
115+ assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have))
116+ assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have))
117+ assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches")
118+ assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label")
119+}
120+
121+func TestStepRanges(t *testing.T) {
122+ t.Parallel()
123+ steps := []domain.CIStep{
124+ {Number: 1, HasLines: true, LogStart: 0},
125+ {Number: 2},
126+ {Number: 3, HasLines: true, LogStart: 4},
127+ {Number: 4, HasLines: true, LogStart: 9},
128+ {Number: 5},
129+ }
130+ got := domain.StepRanges(steps, 12)
131+ assert.Equal(t, []domain.StepRange{{0, 4}, {4, 4}, {4, 9}, {9, 12}, {12, 12}}, got)
132+ assert.EqualValues(t, 3, domain.StepOfLine(steps, 5))
133+ assert.EqualValues(t, 1, domain.StepOfLine(steps, 0))
134+ assert.EqualValues(t, 4, domain.StepOfLine(steps, 11))
135+}
136+
137+func TestJobIDsRoundTripAndRejectOthers(t *testing.T) {
138+ t.Parallel()
139+ id := domain.NewJobID(42, 3, 1)
140+ run, index, combo, ok := domain.ParseJobID(id)
141+ require.True(t, ok)
142+ assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo})
143+ for _, bad := range []string{"", "1-2", "1-2-3-4", "a-b-c", "0-1-1", "1--2", "01-2-3", "../../x", "1-2-3/4"} {
144+ _, _, _, ok := domain.ParseJobID(bad)
145+ assert.False(t, ok, bad)
146+ }
147+}
148+
149+func TestOutputLimitsAndNames(t *testing.T) {
150+ t.Parallel()
151+ assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"}))
152+ assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit")
153+ assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"}))
154+ assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case")
155+ assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))}))
156+ assert.Error(t, domain.CheckOutputs(map[string]string{"a": string(make([]byte, domain.CIMaxOutputBytes/2+1)), "b": string(make([]byte, domain.CIMaxOutputBytes/2+1))}), "1 MiB together")
157+}
158+
159+func TestTokenShapes(t *testing.T) {
160+ t.Parallel()
161+ good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
162+ assert.True(t, domain.IsJobToken(good))
163+ assert.False(t, domain.IsRunnerToken(good))
164+ assert.False(t, domain.IsJobToken(good[:len(good)-1]))
165+ assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
166+ assert.False(t, domain.IsJobToken(good+"="))
167+}
@@ -0,0 +1,167 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+// mergeUnit is a merge request that is ready to merge, with a checks reader.
20+type mergeUnit struct {
21+ *mrUnit
22+ checks *mocks.CIChecksReader
23+ trig *mocks.WorkflowTrigger
24+ head domain.ObjectID
25+}
26+
27+func newMergeUnit(t *testing.T, block bool) *mergeUnit {
28+ t.Helper()
29+ u := &mrUnit{
30+ meta: mocks.NewRepoMetaStore(t),
31+ store: mocks.NewMergeRequestStore(t),
32+ repos: mocks.NewGitRepos(t),
33+ git: mocks.NewMergeGit(t),
34+ repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
35+ }
36+ u.repo.Settings = domain.DefaultRepoSettings()
37+ u.repo.Settings.FailingChecksBlock = block
38+ clock := mocks.NewClock(t)
39+ clock.EXPECT().Now().Return(now).Maybe()
40+ sched := mocks.NewMaintenanceScheduler(t)
41+ sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
42+ m := &mergeUnit{mrUnit: u, checks: mocks.NewCIChecksReader(t), trig: mocks.NewWorkflowTrigger(t), head: someID(t)}
43+ u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44+ Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
45+ Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
46+ CI: m.trig, Checks: m.checks,
47+ })
48+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
49+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{
50+ Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main",
51+ }, nil)
52+ tip, err := domain.ParseObjectID("1111111111111111111111111111111111111111")
53+ require.NoError(t, err)
54+ u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
55+ u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
56+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57+ return m
58+}
59+
60+func (m *mergeUnit) merge() error {
61+ _, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head})
62+ return err
63+}
64+
65+// AC5: the merge rule under FailingChecksBlock.
66+func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
67+ t.Parallel()
68+ check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
69+ return domain.CICheck{Name: name, Status: status, Conclusion: c}
70+ }
71+ tests := []struct {
72+ name string
73+ checks []domain.CICheck
74+ blocked string // part of the message
75+ }{
76+ {"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"},
77+ {"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"},
78+ {"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"},
79+ {"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"},
80+ {"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"},
81+ {"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"},
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ t.Parallel()
86+ m := newMergeUnit(t, true)
87+ m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
88+ // The merge stops at the rule: the branch mock expects no move.
89+ err := m.merge()
90+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
91+ assert.Contains(t, err.Error(), tt.blocked)
92+ })
93+ }
94+ // The cases where the merge goes on (no check, all passing, rule off) need
95+ // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
96+}
97+
98+func TestCombineChecks(t *testing.T) {
99+ t.Parallel()
100+ ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck {
101+ return domain.CICheck{Status: s, Conclusion: c}
102+ }
103+ assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil))
104+ assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)}))
105+ assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")}))
106+ assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending")
107+}
108+
109+func TestLabelsMatchAndRunnerLabels(t *testing.T) {
110+ t.Parallel()
111+ have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64")
112+ assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have)
113+ assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive")
114+ assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have))
115+ assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have))
116+ assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have))
117+ assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches")
118+ assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label")
119+}
120+
121+func TestStepRanges(t *testing.T) {
122+ t.Parallel()
123+ steps := []domain.CIStep{
124+ {Number: 1, HasLines: true, LogStart: 0},
125+ {Number: 2},
126+ {Number: 3, HasLines: true, LogStart: 4},
127+ {Number: 4, HasLines: true, LogStart: 9},
128+ {Number: 5},
129+ }
130+ got := domain.StepRanges(steps, 12)
131+ assert.Equal(t, []domain.StepRange{{0, 4}, {4, 4}, {4, 9}, {9, 12}, {12, 12}}, got)
132+ assert.EqualValues(t, 3, domain.StepOfLine(steps, 5))
133+ assert.EqualValues(t, 1, domain.StepOfLine(steps, 0))
134+ assert.EqualValues(t, 4, domain.StepOfLine(steps, 11))
135+}
136+
137+func TestJobIDsRoundTripAndRejectOthers(t *testing.T) {
138+ t.Parallel()
139+ id := domain.NewJobID(42, 3, 1)
140+ run, index, combo, ok := domain.ParseJobID(id)
141+ require.True(t, ok)
142+ assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo})
143+ for _, bad := range []string{"", "1-2", "1-2-3-4", "a-b-c", "0-1-1", "1--2", "01-2-3", "../../x", "1-2-3/4"} {
144+ _, _, _, ok := domain.ParseJobID(bad)
145+ assert.False(t, ok, bad)
146+ }
147+}
148+
149+func TestOutputLimitsAndNames(t *testing.T) {
150+ t.Parallel()
151+ assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"}))
152+ assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit")
153+ assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"}))
154+ assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case")
155+ assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))}))
156+ assert.Error(t, domain.CheckOutputs(map[string]string{"a": string(make([]byte, domain.CIMaxOutputBytes/2+1)), "b": string(make([]byte, domain.CIMaxOutputBytes/2+1))}), "1 MiB together")
157+}
158+
159+func TestTokenShapes(t *testing.T) {
160+ t.Parallel()
161+ good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
162+ assert.True(t, domain.IsJobToken(good))
163+ assert.False(t, domain.IsRunnerToken(good))
164+ assert.False(t, domain.IsJobToken(good[:len(good)-1]))
165+ assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
166+ assert.False(t, domain.IsJobToken(good+"="))
167+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close