A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 5460cca8a5

5460cca8a555d6d23646aebf992a3bdd82c8fee3

parent: 190ba2060f

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-08-31T20:48:39Z

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

A merge updates the target ref with UpdateRefCAS, which never reaches
post-receive, so nothing that hangs off a ref update ran for it: no push
event for webhooks to subscribe to, and no builds. Pushing the identical
commit to the same branch ran the whole CI config; merging it ran nothing.

The build queueing moves from hookd to control.QueueBranchBuilds so both
paths share one implementation, and the merge path records the same push
event post-receive does.

Mirrors were already marked dirty on merge and are unchanged.

Closes #65
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 (
55 "errors"
66 "fmt"
77 "io"
8 "log/slog"
89 "regexp"
910 "strconv"
1011 "strings"
12 "time"
1113
1214 "gitbay.org/gitbay/internal/ci"
1315 "gitbay.org/gitbay/internal/gitutil"
@@ -437,3 +439,57 @@ func runRunnerDone(c *Ctx, args []string) int {
437439 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
438440 })
439441}
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 (
77 "slices"
88 "strconv"
99 "strings"
10 "time"
1011
1112 "gitbay.org/gitbay/internal/gitutil"
1213 "gitbay.org/gitbay/internal/policy"
@@ -1024,6 +1025,14 @@ func runMRMerge(c *Ctx, args []string) int {
10241025 ProcessMRDescription(c.Store, repo, mr, c.User.ID)
10251026 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
10261027 }
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())
10271036 c.Store.MarkMirrorsDirty(repo.ID, "push")
10281037 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
10291038 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) {
241241 }
242242}
243243
244// queueBuilds reads .gitbay/ci.yml at the pushed commit and creates one
245// pending build per job, with a pending commit status the runner resolves.
246// A broken config surfaces as a failed "ci/config" status, not silence.
244// queueBuilds queues the push jobs for a branch update. The work is
245// shared with the merge path, which moves a ref without reaching a hook.
247246func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) {
248 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
249 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
250 if err != nil {
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 }
247 control.QueueBranchBuilds(
248 s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
249 repo, userID, branch, sha, time.Now())
290250}
291251
292252// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.