A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit e7586fc527

e7586fc527c6cf5008c857e0d2e6d8f7d43a9d5e

parent: af7479c3da

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T20:44:15Z

Mail the repo's notify targets when a build fails

Subject names the job and ref; the body carries the last 2KB of log
and the build page link. Green builds stay silent; the runner account
is the actor, so owners are never excluded as self-notifications.
Failed scheduled jobs — the stale-feed alarms — now reach an inbox
instead of waiting to be noticed.
e2e/ci_test.go +43
@@ -38,6 +38,49 @@ func (i *instance) runnerOnce(t *testing.T, key string) string {
3838 return string(out)
3939 }
4040
41// A failed build mails the repo owner with the log tail; green builds
42// stay silent.
43func TestBuildFailureMail(t *testing.T) {
44 smtp := startFakeSMTP(t)
45 inst := startInstanceWith(t, fmt.Sprintf(
46 "[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
47 inst.runner = buildRunner(t)
48 aliceKey := inst.newKey(t, "alice")
49 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
50 "--email", "alice@example.test", "--verified")
51 runnerKey := inst.newKey(t, "ci")
52 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
53
54 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
55 t.Fatalf("repo create: %s", errOut)
56 }
57 work := t.TempDir()
58 env := inst.gitEnv(aliceKey)
59 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
60 dir := filepath.Join(work, "w")
61 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
62 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
63 "jobs:\n ok:\n steps:\n - echo fine\n broken:\n steps:\n - echo the dataset went stale && false\n"), 0o644)
64 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
65 mustGit(t, dir, env, "add", ".")
66 mustGit(t, dir, env, "commit", "-q", "-m", "base")
67 mustGit(t, dir, env, "push", "-q", "origin", "main")
68
69 inst.runnerOnce(t, runnerKey) // broken (sorts first)
70 inst.runnerOnce(t, runnerKey) // ok
71 mail := smtp.waitFor(t, "alice@example.test", "failed")
72 if !strings.Contains(mail, "broken") || !strings.Contains(mail, "the dataset went stale") ||
73 !strings.Contains(mail, "/alice/app/builds/") {
74 t.Fatalf("failure mail missing detail:\n%s", mail)
75 }
76 // Only the failure mailed: no message mentions the green job.
77 for _, m := range smtp.mailTo("alice@example.test") {
78 if strings.Contains(m, "build") && strings.Contains(m, " ok ") && strings.Contains(m, "failed") == false {
79 t.Fatalf("green build mailed:\n%s", m)
80 }
81 }
82}
83
4184 func TestCI(t *testing.T) {
4285 inst := startInstance(t)
4386 inst.runner = buildRunner(t)
internal/control/build.go +16
@@ -340,6 +340,22 @@ func runRunnerDone(c *Ctx, args []string) int {
340340 }
341341 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
342342 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
343 // A red build mails the repo's notify targets with the log tail — a
344 // failed scheduled job must not wait to be noticed.
345 if args[1] == "failure" {
346 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
347 tail := ""
348 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
349 if len(log) > 2000 {
350 log = log[len(log)-2000:]
351 }
352 tail = string(log)
353 }
354 notifyUsers(c, targets,
355 fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
356 fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url))
357 }
358 }
343359 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
344360 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
345361 })