Commit 882f09f5a0

882f09f5a03d4f1b019a49f8e3f5d81011e587f7

parent: 993ef5f203

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

cmc <hello@cleberg.net> · 2026-09-03 21:41 UTC

ci: build merge request heads in the target repository

Builds queued only for branch pushes to the pushed repository, so a
merge request from a fork had no ci/<job> statuses in the target and
require-checks refused it with nothing reported. The head is already
fetched into the target at refs/merge-requests/<n>/head; that fetch,
on create and on every push to the source branch, now queues the
target's push jobs against it. The per-commit check skips a head the
branch push already built, so a same-repository merge request is not
built twice.

A head from another repository runs untrusted: migration 0032 adds
builds.trusted, runner next withholds the repository's secrets for an
untrusted build, and the runner fetches the merge request ref before
checkout since a clone does not carry it.

TestForkMRHeadIsBuilt: a fork's merge request queues both jobs in the
target, the claim carries no secrets, the runner builds the head, and
require-checks then admits the merge.

Closes #98

Layout: unified · split

cmd/gitbay-runner/main.go +8 −4
@@ -251,10 +251,14 @@ func (r *runner) run(j job) bool {
251251 cloneURL := r.cloneBase + "/" + j.Repo + ".git"
252252 deadline := time.Now().Add(r.timeout)
253253 fmt.Fprintf(sink, "$ git clone %s (%.10s)\n", cloneURL, j.SHA)
254 for _, args := range [][]string{
255 {"clone", "-q", cloneURL, dir},
256 {"-C", dir, "checkout", "-q", j.SHA},
257 } {
254 // A merge request head lives under refs/merge-requests/, which a
255 // clone does not fetch; ask for the ref before checking out.
256 steps := [][]string{{"clone", "-q", cloneURL, dir}}
257 if strings.HasPrefix(j.Ref, "refs/") {
258 steps = append(steps, []string{"-C", dir, "fetch", "-q", "origin", j.Ref})
259 }
260 steps = append(steps, []string{"-C", dir, "checkout", "-q", j.SHA})
261 for _, args := range steps {
258262 cmd := exec.Command("git", args...)
259263 cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+gitSSH, "GIT_TERMINAL_PROMPT=0")
260264 cmd.Stdout, cmd.Stderr = sink, sink
e2e/mrbuilds_test.go added +129
@@ -0,0 +1,129 @@
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// A merge request head is fetched into the target repository, and the
13// target's push jobs run against it there, so a fork's merge request has
14// ci/<job> statuses for require-checks to gate on. Builds used to queue
15// only for branch pushes to the pushed repository, which left a fork's
16// merge request unbuildable and, under require-checks, unmergeable (#98).
17// A head from another repository runs without the target's secrets.
18func TestForkMRHeadIsBuilt(t *testing.T) {
19 inst := startInstance(t)
20 inst.runner = buildRunner(t)
21 aliceKey := inst.newKey(t, "alice")
22 bobKey := inst.newKey(t, "bob")
23 runnerKey := inst.newKey(t, "ci")
24 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
25 "--email", "alice@example.test", "--verified")
26 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
27 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
28
29 // alice/app: two push jobs, a secret, require-checks.
30 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
31 t.Fatalf("repo create: %s", errOut)
32 }
33 work := t.TempDir()
34 env := inst.gitEnv(aliceKey)
35 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
36 dir := filepath.Join(work, "w")
37 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
38 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
39 "jobs:\n one:\n steps:\n - test -z \"$TOKEN\"\n two:\n steps:\n - echo two\n"), 0o644)
40 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0o644)
41 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
42 mustGit(t, dir, env, "add", ".")
43 mustGit(t, dir, env, "commit", "-q", "-m", "base")
44 mustGit(t, dir, env, "push", "-q", "origin", "main")
45 // The branch push queued two builds for main; clear them so the
46 // queue holds only what the merge request adds.
47 for _, n := range []string{"1", "2"} {
48 if _, _, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", n); code != 0 {
49 t.Fatalf("build cancel %s failed", n)
50 }
51 }
52 if _, _, code := inst.ssh(t, aliceKey, "s3cret\n", "repo", "secret", "set", "alice/app", "TOKEN"); code != 0 {
53 t.Fatal("secret set failed")
54 }
55 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-checks", "alice/app", "on"); code != 0 {
56 t.Fatal("require-checks failed")
57 }
58
59 // bob forks, pushes a branch to the fork, opens the merge request.
60 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/app"); code != 0 {
61 t.Fatalf("fork: %s", errOut)
62 }
63 bwork := t.TempDir()
64 benv := inst.gitEnv(bobKey)
65 mustGit(t, bwork, benv, "clone", inst.sshURL("bob/app"), "w")
66 bdir := filepath.Join(bwork, "w")
67 mustGit(t, bdir, benv, "checkout", "-q", "-b", "feat")
68 os.WriteFile(filepath.Join(bdir, "f.txt"), []byte("y\n"), 0o644)
69 mustGit(t, bdir, benv, "add", ".")
70 mustGit(t, bdir, benv, "commit", "-q", "-m", "change")
71 mustGit(t, bdir, benv, "push", "-q", "origin", "feat")
72 head := strings.TrimSpace(mustGit(t, bdir, benv, "rev-parse", "HEAD"))
73 // The fork carries the same ci.yml, so bob's push queued its own
74 // builds; cancel them so the runner's next claim is the target's.
75 for _, n := range []string{"1", "2"} {
76 if _, _, code := inst.ssh(t, bobKey, "", "build", "cancel", "bob/app", n); code != 0 {
77 t.Fatalf("build cancel bob/app %s failed", n)
78 }
79 }
80 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
81 "--source", "bob/app:feat", "--target", "main", "--title", "change"); code != 0 {
82 t.Fatalf("mr create: %s", errOut)
83 }
84
85 // Two builds queued in the target, at the merge request ref.
86 out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app", "--json")
87 if strings.Count(out, `"status":"pending"`) != 2 || !strings.Contains(out, `"ref":"refs/merge-requests/1/head"`) {
88 t.Fatalf("expected two pending builds at the MR ref:\n%s", out)
89 }
90 if strings.Count(out, head[:10]) < 2 {
91 t.Fatalf("builds are not for the MR head %s:\n%s", head[:10], out)
92 }
93
94 // The claim carries no secrets for a head from another repository.
95 out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/app", "--json")
96 if code != 0 {
97 t.Fatalf("runner next: %s", errOut)
98 }
99 var claim struct {
100 Data struct {
101 ID int64 `json:"id"`
102 Job string `json:"job"`
103 Secrets map[string]string `json:"secrets"`
104 } `json:"data"`
105 }
106 json.Unmarshal([]byte(out), &claim)
107 if claim.Data.Job != "one" || len(claim.Data.Secrets) != 0 {
108 t.Fatalf("fork build claimed with secrets or wrong job:\n%s", out)
109 }
110 if _, _, code := inst.ssh(t, runnerKey, "", "runner", "done", fmt.Sprint(claim.Data.ID), "success"); code != 0 {
111 t.Fatal("runner done failed")
112 }
113
114 // The real runner fetches the merge request ref and runs the second job.
115 log := inst.runnerOnce(t, runnerKey)
116 if !strings.Contains(log, "two") {
117 t.Fatalf("runner did not run the second job:\n%s", log)
118 }
119 out, errOut, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", head, "--json")
120 if strings.Count(out, `"state":"success"`) != 2 {
121 builds, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app", "--json")
122 t.Fatalf("statuses on the MR head:\n%s%s\nbuilds:\n%s\nrunner log:\n%s", out, errOut, builds, log)
123 }
124
125 // require-checks is satisfied by the builds on the head.
126 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1"); code != 0 {
127 t.Fatalf("merge under require-checks: %s", errOut)
128 }
129}
internal/ci/sched.go +1 −1
@@ -101,7 +101,7 @@ func (s *Scheduler) RunDue(now time.Time) {
101101 continue
102102 }
103103 steps, _ := json.Marshal(job.Steps)
104 n, err := s.St.CreateBuild(repo.ID, job.Name, sha, repo.DefaultBranch, string(steps))
104 n, err := s.St.CreateBuild(repo.ID, job.Name, sha, repo.DefaultBranch, string(steps), true)
105105 if err != nil {
106106 slog.Error("scheduler: queueing build", "repo", repo.Path(), "job", job.Name, "err", err)
107107 continue
internal/control/build.go +32 −7
@@ -227,7 +227,7 @@ func runBuildTrigger(c *Ctx, args []string) int {
227227 continue
228228 }
229229 steps, _ := json.Marshal(j.Steps)
230 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps))
230 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps), true)
231231 if err != nil {
232232 return c.fail(protocol.ExitFailure, "%v", err)
233233 }
@@ -347,9 +347,12 @@ func runRunnerNext(c *Ctx, args []string) int {
347347 json.Unmarshal([]byte(b.Steps), &steps)
348348 // Secrets ride the claim: this channel is admin-only and the values
349349 // land in the build's environment, nowhere else.
350 secrets, err := c.Store.BuildSecrets(b.RepoID)
351 if err != nil {
352 return c.fail(protocol.ExitFailure, "%v", err)
350 var secrets map[string]string
351 if b.Trusted {
352 secrets, err = c.Store.BuildSecrets(b.RepoID)
353 if err != nil {
354 return c.fail(protocol.ExitFailure, "%v", err)
355 }
353356 }
354357 d := struct {
355358 ID int64 `json:"id"`
@@ -499,6 +502,28 @@ func runRunnerDone(c *Ctx, args []string) int {
499502func QueueBranchBuilds(
500503 st *store.Store, root, siteURL string,
501504 repo store.Repo, userID int64, branch, sha string, now time.Time,
505) {
506 queueJobs(st, root, siteURL, repo, userID, branch, sha, now, true, branch == repo.DefaultBranch)
507}
508
509// QueueMRBuilds queues the push jobs for a merge request head fetched
510// from another repository, which the target holds at
511// refs/merge-requests/<n>/head, so a fork's merge request has ci/<job>
512// statuses for require-checks to gate on (#98). The head is untrusted:
513// its build runs without the target's secrets. A same-repository head is
514// the branch push's job and is not queued here; a failed one is rebuilt
515// when it lands, not when it is proposed.
516func QueueMRBuilds(
517 st *store.Store, root, siteURL string,
518 repo store.Repo, userID, n int64, sha string,
519) {
520 queueJobs(st, root, siteURL, repo, userID, mrHeadRef(n), sha, time.Now(), false, false)
521}
522
523func queueJobs(
524 st *store.Store, root, siteURL string,
525 repo store.Repo, userID int64, ref, sha string, now time.Time,
526 trusted, syncSchedules bool,
502527) {
503528 dir := RepoDir(root, repo.OwnerName, repo.Name)
504529 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
@@ -531,7 +556,7 @@ func QueueBranchBuilds(
531556 // Scheduled jobs run on their cron, not on push; a default-branch
532557 // push (re)registers them.
533558 if j.Schedule != "" {
534 if branch == repo.DefaultBranch {
559 if syncSchedules {
535560 schedules = append(schedules, store.Schedule{
536561 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
537562 NextRun: ci.NextRun(j.Schedule, now),
@@ -540,7 +565,7 @@ func QueueBranchBuilds(
540565 continue
541566 }
542567 steps, _ := json.Marshal(j.Steps)
543 n, err := st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
568 n, err := st.CreateBuild(repo.ID, j.Name, sha, ref, string(steps), trusted)
544569 if err != nil {
545570 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
546571 continue
@@ -548,7 +573,7 @@ func QueueBranchBuilds(
548573 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), n)
549574 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
550575 }
551 if branch == repo.DefaultBranch {
576 if syncSchedules {
552577 if err := st.SyncSchedules(repo.ID, schedules); err != nil {
553578 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
554579 }
internal/control/mr.go +3
@@ -314,6 +314,9 @@ func runMRCreate(c *Ctx, args []string) int {
314314 if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
315315 return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
316316 }
317 if srcRepo.ID != repo.ID {
318 QueueMRBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL, repo, c.User.ID, n, headSHA)
319 }
317320 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
318321 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
319322 notifyUsers(c, targets, mrSubject(repo, n, title),
internal/hookd/hookd.go +5 −1
@@ -234,6 +234,10 @@ func (s *Server) postReceive(req Request) {
234234 if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil {
235235 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
236236 }
237 if srcRepo.ID != target.ID {
238 control.QueueMRBuilds(s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
239 target, req.UserID, mr.Number, u.New)
240 }
237241 if mr.State == "source_gone" {
238242 s.st.SetMRState(mr.ID, "open") // branch came back
239243 }
@@ -274,7 +278,7 @@ func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed strin
274278 continue
275279 }
276280 steps, _ := json.Marshal(j.Steps)
277 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps))
281 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), true)
278282 if err != nil {
279283 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
280284 continue
internal/store/builds.go +10 −5
@@ -22,6 +22,9 @@ type Build struct {
2222 CreatedAt string
2323 StartedAt string
2424 FinishedAt string
25 // Trusted is false for a merge request head fetched from another
26 // repository: its steps run without the target's secrets.
27 Trusted bool
2528}
2629
2730// MaxBuildLog caps a build's stored log; appends past it are dropped.
@@ -35,7 +38,7 @@ var truncNotice = []byte("\n[log truncated: reached the " +
3538
3639// CreateBuild allocates the per-repo build number in the same transaction
3740// as the insert, like issue and MR numbers.
38func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON string) (int64, error) {
41func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON string, trusted bool) (int64, error) {
3942 tx, err := s.DB.Begin()
4043 if err != nil {
4144 return 0, err
@@ -49,21 +52,23 @@ func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON string) (int6
4952 return 0, err
5053 }
5154 if _, err := tx.Exec(
52 "INSERT INTO builds (repo_id, number, job, sha, ref, steps) VALUES (?, ?, ?, ?, ?, ?)",
53 repoID, n, job, sha, ref, stepsJSON); err != nil {
55 "INSERT INTO builds (repo_id, number, job, sha, ref, steps, trusted) VALUES (?, ?, ?, ?, ?, ?, ?)",
56 repoID, n, job, sha, ref, stepsJSON, trusted); err != nil {
5457 return 0, err
5558 }
5659 return n, tx.Commit()
5760}
5861
5962const buildSelect = `
60 SELECT id, repo_id, number, job, sha, ref, steps, status, created_at, started_at, finished_at
63 SELECT id, repo_id, number, job, sha, ref, steps, status, created_at, started_at, finished_at, trusted
6164 FROM builds`
6265
6366func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
6467 var b Build
68 var trusted int
6569 err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps,
66 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt)
70 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &trusted)
71 b.Trusted = trusted != 0
6772 return b, err
6873}
6974
internal/store/builds_test.go +7 −7
@@ -21,11 +21,11 @@ func TestReapStaleBuilds(t *testing.T) {
2121 t.Fatal(err)
2222 }
2323
24 stuck, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`)
24 stuck, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, true)
2525 if err != nil {
2626 t.Fatal(err)
2727 }
28 fresh, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`)
28 fresh, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`, true)
2929 if err != nil {
3030 t.Fatal(err)
3131 }
@@ -87,11 +87,11 @@ func TestBuildsForCommitTiming(t *testing.T) {
8787 }
8888 // Two runs of the same job on one commit: the retry is what counts.
8989 for range 2 {
90 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`); err != nil {
90 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`, true); err != nil {
9191 t.Fatal(err)
9292 }
9393 }
94 if _, err := s.CreateBuild(repoID, "lint", "def456", "main", `["true"]`); err != nil {
94 if _, err := s.CreateBuild(repoID, "lint", "def456", "main", `["true"]`, true); err != nil {
9595 t.Fatal(err)
9696 }
9797 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',
@@ -140,10 +140,10 @@ func TestClaimBuildScopedToRepos(t *testing.T) {
140140 t.Fatal(err)
141141 }
142142 // Queued first, so an unscoped claim would take it.
143 if _, err := s.CreateBuild(theirs, "evil", "abc123", "main", `["true"]`); err != nil {
143 if _, err := s.CreateBuild(theirs, "evil", "abc123", "main", `["true"]`, true); err != nil {
144144 t.Fatal(err)
145145 }
146 wanted, err := s.CreateBuild(mine, "deploy", "def456", "main", `["true"]`)
146 wanted, err := s.CreateBuild(mine, "deploy", "def456", "main", `["true"]`, true)
147147 if err != nil {
148148 t.Fatal(err)
149149 }
@@ -182,7 +182,7 @@ func TestBuildLogSaysWhenItTruncates(t *testing.T) {
182182 if _, err := s.CreateRepo("user", uid, "orgo", "public"); err != nil {
183183 t.Fatal(err)
184184 }
185 id, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`)
185 id, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, true)
186186 if err != nil {
187187 t.Fatal(err)
188188 }
internal/store/migrations/0032_build_trusted.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE builds DROP COLUMN trusted;
internal/store/migrations/0032_build_trusted.up.sql added +3
@@ -0,0 +1,3 @@
1-- A build from a merge request head fetched out of another repository
2-- runs code the target's owners did not write; it gets no secrets.
3ALTER TABLE builds ADD COLUMN trusted INTEGER NOT NULL DEFAULT 1;
internal/store/statuses_test.go +1 −1
@@ -17,7 +17,7 @@ func TestChecksForCommit(t *testing.T) {
1717 if err != nil {
1818 t.Fatal(err)
1919 }
20 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`); err != nil {
20 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`, true); err != nil {
2121 t.Fatal(err)
2222 }
2323 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',