Commit 5460cca8a5
Verified · cmc ci/build: success ci/test: success
e2e/ci_merge_test.go added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 14 | func 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 | ||
| 69 | func (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 | 5 | "errors" |
| 6 | 6 | "fmt" |
| 7 | 7 | "io" |
| 8 | "log/slog" | |
| 8 | 9 | "regexp" |
| 9 | 10 | "strconv" |
| 10 | 11 | "strings" |
| 12 | "time" | |
| 11 | 13 | |
| 12 | 14 | "gitbay.org/gitbay/internal/ci" |
| 13 | 15 | "gitbay.org/gitbay/internal/gitutil" |
| @@ -437,3 +439,57 @@ func runRunnerDone(c *Ctx, args []string) int { | ||
| 437 | 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. | |
| 450 | func 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 | 7 | "slices" |
| 8 | 8 | "strconv" |
| 9 | 9 | "strings" |
| 10 | "time" | |
| 10 | 11 | |
| 11 | 12 | "gitbay.org/gitbay/internal/gitutil" |
| 12 | 13 | "gitbay.org/gitbay/internal/policy" |
| @@ -1024,6 +1025,14 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 1024 | 1025 | ProcessMRDescription(c.Store, repo, mr, c.User.ID) |
| 1025 | 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 | 1036 | c.Store.MarkMirrorsDirty(repo.ID, "push") |
| 1028 | 1037 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1029 | 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 | |
| 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. | |
| 247 | 246 | func (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()) | |
| 290 | 250 | } |
| 291 | 251 | |
| 292 | 252 | // queueTagBuilds runs the jobs whose tag pattern matches a pushed tag. |