Fire the ref-update work when a merge moves a branch !139

merged merged by cmc on 2026-08-31 20:58 UTC · krz/gitbay:merge-fires-ref-events into main

4 files changed, +143 −45

Layout: unified · split

e2e/ci_merge_test.go added +73
@@ -0,0 +1,73 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// A merge moves the target ref without reaching post-receive, so the
11// ref-update work has to happen in the merge path. Before it did, merging
12// an MR into a repository with a CI config queued nothing, while pushing
13// the identical commit ran the whole config.
14func TestMergeQueuesBuildsAndRecordsPush(t *testing.T) {
15 inst := startInstance(t)
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
18 "--email", "alice@example.test", "--verified")
19
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
21 t.Fatalf("repo create: %s", errOut)
22 }
23 work := t.TempDir()
24 env := inst.gitEnv(aliceKey)
25 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
26 dir := filepath.Join(work, "w")
27 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
28 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
29 "jobs:\n unit:\n steps:\n - echo hello\n"), 0o644)
30 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
31 mustGit(t, dir, env, "add", ".")
32 mustGit(t, dir, env, "commit", "-q", "-m", "base")
33 mustGit(t, dir, env, "push", "-q", "origin", "main")
34
35 // A feature branch, pushed and merged rather than pushed to main.
36 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
37 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0o644)
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "a change")
40 mustGit(t, dir, env, "push", "-q", "origin", "feature")
41
42 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
43 "--source", "feature", "--target", "main", "--title", "'a change'"); code != 0 {
44 t.Fatalf("mr create: %s", errOut)
45 }
46 before := strings.Count(inst.buildList(t, aliceKey), "\n")
47
48 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1",
49 "--strategy", "merge"); code != 0 {
50 t.Fatalf("mr merge: %s", errOut)
51 }
52 merged := strings.TrimSpace(mustGit(t, dir, env, "ls-remote", "origin", "refs/heads/main"))
53 sha := strings.Fields(merged)[0]
54
55 // The merge queued the branch's jobs at the merge commit.
56 out := inst.buildList(t, aliceKey)
57 if strings.Count(out, "\n") <= before {
58 t.Fatalf("merge queued no build:\n%s", out)
59 }
60 if !strings.Contains(out, "unit\tpending") {
61 t.Fatalf("merge did not queue the unit job:\n%s", out)
62 }
63 status, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha)
64 if !strings.Contains(status, "ci/unit") || !strings.Contains(status, "pending") {
65 t.Fatalf("no pending status on the merge commit %s:\n%s", sha, status)
66 }
67}
68
69func (i *instance) buildList(t *testing.T, key string) string {
70 t.Helper()
71 out, _, _ := i.ssh(t, key, "", "build", "list", "alice/app")
72 return out
73}
internal/control/build.go +56
@@ -5,9 +5,11 @@ import (
5 "errors" 5 "errors"
6 "fmt" 6 "fmt"
7 "io" 7 "io"
8 "log/slog"
8 "regexp" 9 "regexp"
9 "strconv" 10 "strconv"
10 "strings" 11 "strings"
12 "time"
11 13
12 "gitbay.org/gitbay/internal/ci" 14 "gitbay.org/gitbay/internal/ci"
13 "gitbay.org/gitbay/internal/gitutil" 15 "gitbay.org/gitbay/internal/gitutil"
@@ -437,3 +439,57 @@ func runRunnerDone(c *Ctx, args []string) int {
437 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1]) 439 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
438 }) 440 })
439} 441}
442
443// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
444// build per push job, with a pending commit status the runner resolves.
445// A broken config surfaces as a failed "ci/config" status, not silence.
446//
447// Both paths that move a branch call this: post-receive for a push, and
448// the merge path for a merge, which updates the ref directly and so never
449// reaches a hook.
450func QueueBranchBuilds(
451 st *store.Store, root, siteURL string,
452 repo store.Repo, userID int64, branch, sha string, now time.Time,
453) {
454 dir := RepoDir(root, repo.OwnerName, repo.Name)
455 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
456 if err != nil {
457 return // no CI config at this commit
458 }
459 jobs, err := ci.Parse(raw)
460 if err != nil {
461 st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
462 return
463 }
464 var schedules []store.Schedule
465 for _, j := range jobs {
466 // Tag jobs run on matching tag pushes only.
467 if j.Tags != "" {
468 continue
469 }
470 // Scheduled jobs run on their cron, not on push; a default-branch
471 // push (re)registers them.
472 if j.Schedule != "" {
473 if branch == repo.DefaultBranch {
474 schedules = append(schedules, store.Schedule{
475 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
476 NextRun: ci.NextRun(j.Schedule, now),
477 })
478 }
479 continue
480 }
481 steps, _ := json.Marshal(j.Steps)
482 n, err := st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
483 if err != nil {
484 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
485 continue
486 }
487 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), n)
488 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
489 }
490 if branch == repo.DefaultBranch {
491 if err := st.SyncSchedules(repo.ID, schedules); err != nil {
492 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
493 }
494 }
495}
internal/control/mr.go +9
@@ -7,6 +7,7 @@ import (
7 "slices" 7 "slices"
8 "strconv" 8 "strconv"
9 "strings" 9 "strings"
10 "time"
10 11
11 "gitbay.org/gitbay/internal/gitutil" 12 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy" 13 "gitbay.org/gitbay/internal/policy"
@@ -1024,6 +1025,14 @@ func runMRMerge(c *Ctx, args []string) int {
1024 ProcessMRDescription(c.Store, repo, mr, c.User.ID) 1025 ProcessMRDescription(c.Store, repo, mr, c.User.ID)
1025 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA) 1026 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1026 } 1027 }
1028 // A merge moves the ref directly, so it never reaches post-receive and
1029 // none of the ref-update work fires on its own. The event webhooks
1030 // subscribe to, and the branch's CI jobs, happen here instead.
1031 c.Store.RecordEvent(repo.ID, c.User.ID, "push", fmt.Sprintf(
1032 `{"ref":%q,"old":%q,"new":%q,"forced":false,"deleted":false}`,
1033 targetRef, targetSHA, newSHA))
1034 QueueBranchBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL,
1035 repo, c.User.ID, mr.TargetRef, newSHA, time.Now())
1027 c.Store.MarkMirrorsDirty(repo.ID, "push") 1036 c.Store.MarkMirrorsDirty(repo.ID, "push")
1028 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1037 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1029 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), 1038 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
internal/hookd/hookd.go +5 −45
@@ -241,52 +241,12 @@ func (s *Server) postReceive(req Request) {
241 } 241 }
242} 242}
243 243
244// queueBuilds reads .gitbay/ci.yml at the pushed commit and creates one 244// queueBuilds queues the push jobs for a branch update. The work is
245// pending build per job, with a pending commit status the runner resolves. 245// shared with the merge path, which moves a ref without reaching a hook.
246// A broken config surfaces as a failed "ci/config" status, not silence.
247func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) { 246func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) {
248 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) 247 control.QueueBranchBuilds(
249 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16) 248 s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
250 if err != nil { 249 repo, userID, branch, sha, time.Now())
251 return // no CI config at this commit
252 }
253 jobs, err := ci.Parse(raw)
254 if err != nil {
255 s.st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
256 return
257 }
258 now := time.Now()
259 var schedules []store.Schedule
260 for _, j := range jobs {
261 // Tag jobs run on matching tag pushes only.
262 if j.Tags != "" {
263 continue
264 }
265 // Scheduled jobs run on their cron, not on push; a default-branch
266 // push (re)registers them.
267 if j.Schedule != "" {
268 if branch == repo.DefaultBranch {
269 schedules = append(schedules, store.Schedule{
270 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
271 NextRun: ci.NextRun(j.Schedule, now),
272 })
273 }
274 continue
275 }
276 steps, _ := json.Marshal(j.Steps)
277 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
278 if err != nil {
279 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
280 continue
281 }
282 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
283 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
284 }
285 if branch == repo.DefaultBranch {
286 if err := s.st.SyncSchedules(repo.ID, schedules); err != nil {
287 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
288 }
289 }
290} 250}
291 251
292// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag. 252// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.