Commit 35867bdb7c

35867bdb7c72d6bf1584c23e8dc4bdf25cf75f35

parent: 8e6fc2062c

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

cmc <hello@cleberg.net> · 2026-09-13 06:31 UTC

control: require_checks waits only on CI that would report

A head with no statuses is unmet only when its .gitbay/ci.yml carries a
job a push would run. A repository with no CI configuration, or one
whose jobs are all schedule- or tag-only, has nothing to wait for.

Closes #216
.gitbay/wiki/API.org +4 −2
@@ -109,8 +109,10 @@ One row per (commit, context): re-reporting updates in place. States:
109109worst of them. Statuses appear on commit pages, MR pages, and
110110=mr show=, each with =updated_at=; a =ci/<job>= status also carries
111111=duration=, read from the build behind it, once that build has
112finished. With =repo settings require-checks <repo> on=, merging
113requires the MR head to carry statuses and all of them green. Each
112finished. With =repo settings require-checks <repo> on=, every status
113on the MR head must be green, and a head whose =.gitbay/ci.yml= has a
114job a push would run must carry some; a repository with no CI
115configuration, or only scheduled and tag jobs, merges. Each
114116report also emits a =status= event to webhooks.
115117
116118* Webhooks
e2e/ci_nojobs_test.go added +50
@@ -0,0 +1,50 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7)
8
9// require_checks on a repository with no CI configuration used to refuse
10// every merge with "none were reported", and the only way out was turning
11// the setting off. Nothing was ever going to report, so the gate has
12// nothing to wait for.
13func TestRequireChecksWithoutCIConfigMerges(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
17 "--email", "alice@example.test", "--verified")
18
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-checks", "alice/app", "on"); code != 0 {
23 t.Fatalf("require-checks on: %s", errOut)
24 }
25
26 work := t.TempDir()
27 env := inst.gitEnv(aliceKey)
28 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
29 dir := filepath.Join(work, "w")
30 os.WriteFile(filepath.Join(dir, "README"), []byte("x\n"), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "base")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35
36 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
37 os.WriteFile(filepath.Join(dir, "README"), []byte("y\n"), 0o644)
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "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", "change"); code != 0 {
44 t.Fatalf("mr create: %s", errOut)
45 }
46 if out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1",
47 "--strategy", "merge"); code != 0 {
48 t.Fatalf("mr merge refused under require_checks with no CI: %s %s", out, errOut)
49 }
50}
internal/control/checksgate_test.go added +99
@@ -0,0 +1,99 @@
1package control
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// gatesForHead builds a repository with require_checks on, a bare dir
13// holding the given .gitbay/ci.yml (empty string for none), and one MR
14// whose head carries no statuses at all.
15func gatesForHead(t *testing.T, ciYML string) GatesOut {
16 t.Helper()
17 st, repo, uid := newQueueTestRepo(t)
18 if _, err := st.UpdateRepoSettings(repo.ID, func(set *store.RepoSettings) { set.RequireChecks = true }); err != nil {
19 t.Fatal(err)
20 }
21 repo, err := st.RepoByID(repo.ID)
22 if err != nil {
23 t.Fatal(err)
24 }
25
26 git := gitRunner(t)
27 root := t.TempDir()
28 src := filepath.Join(root, "src")
29 os.MkdirAll(src, 0o755)
30 git(root, "init", "-q", "-b", "main", "src")
31 os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644)
32 git(src, "add", ".")
33 git(src, "commit", "-q", "-m", "base")
34 targetSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
35 git(src, "checkout", "-q", "-b", "feature")
36 if ciYML != "" {
37 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
38 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(ciYML), 0o644)
39 }
40 os.WriteFile(filepath.Join(src, "README"), []byte("y\n"), 0o644)
41 git(src, "add", ".")
42 git(src, "commit", "-q", "-m", "change")
43 headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
44
45 dir := RepoDir(root, repo.OwnerName, repo.Name)
46 os.MkdirAll(filepath.Dir(dir), 0o755)
47 git(root, "clone", "-q", "--bare", src, dir)
48
49 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", headSHA, "md", false); err != nil {
50 t.Fatal(err)
51 }
52 mr, err := st.MRByNumber(repo.ID, 1)
53 if err != nil {
54 t.Fatal(err)
55 }
56
57 g, err := MergeGates(st, repo, mr, dir, targetSHA, headSHA)
58 if err != nil {
59 t.Fatal(err)
60 }
61 return g
62}
63
64func checksUnmet(g GatesOut) bool {
65 for _, u := range g.Unmet {
66 if strings.Contains(u, "green checks") {
67 return true
68 }
69 }
70 return false
71}
72
73// A repository with require_checks on and no CI configuration at its head
74// can never report a status, so the gate has nothing to wait for and the
75// merge must go through rather than be refused forever.
76func TestRequireChecksPassesWithNoCIConfig(t *testing.T) {
77 if g := gatesForHead(t, ""); checksUnmet(g) {
78 t.Fatalf("refused a head with no CI configuration: %v", g.Unmet)
79 }
80}
81
82// Jobs that only run on a schedule or on tags never report on a merge
83// request head either.
84func TestRequireChecksPassesWithNoPushJobs(t *testing.T) {
85 cfg := "jobs:\n nightly:\n schedule: \"0 3 * * *\"\n steps:\n - echo hi\n" +
86 " release:\n tags: \"v*\"\n steps:\n - echo hi\n"
87 if g := gatesForHead(t, cfg); checksUnmet(g) {
88 t.Fatalf("refused a head whose jobs never run on a push: %v", g.Unmet)
89 }
90}
91
92// A push job at the head should have reported something. Silence there
93// means CI did not run, which is what the gate is for.
94func TestRequireChecksRefusesSilentPushJob(t *testing.T) {
95 cfg := "jobs:\n unit:\n steps:\n - echo hi\n"
96 if g := gatesForHead(t, cfg); !checksUnmet(g) {
97 t.Fatalf("allowed a head whose push job reported nothing: %v", g.Unmet)
98 }
99}
internal/control/mr.go +30 −3
@@ -9,6 +9,7 @@ import (
99 "strings"
1010 "time"
1111
12 "gitbay.org/gitbay/internal/ci"
1213 "gitbay.org/gitbay/internal/gitutil"
1314 "gitbay.org/gitbay/internal/policy"
1415 "gitbay.org/gitbay/internal/protocol"
@@ -1243,6 +1244,30 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA
12431244 return -1
12441245}
12451246
1247// headRunsJobs reports whether a push of this head would have queued or
1248// skipped a job, and so left it a status. A repository with no CI
1249// configuration, or one whose jobs all wait on a schedule or a tag, can
1250// never satisfy require_checks, and refusing its merges leaves no remedy
1251// but turning the setting off. A configuration that will not parse
1252// counts as running jobs: the push recorded a ci/config failure for it,
1253// so the head is not silent and this is not the branch that decides.
1254func headRunsJobs(dir, headSHA string) bool {
1255 raw, err := gitutil.ReadBlob(dir, headSHA, ci.ConfigPath, 1<<16)
1256 if err != nil {
1257 return false
1258 }
1259 jobs, err := ci.Parse(raw)
1260 if err != nil {
1261 return true
1262 }
1263 for _, j := range jobs {
1264 if j.Tags == "" && j.Schedule == "" {
1265 return true
1266 }
1267 }
1268 return false
1269}
1270
12461271// MergeGates computes where a merge request stands against its
12471272// repository's gates: draft, require_checks, require_approvals (fresh,
12481273// non-author, latest review per reviewer from someone who can write; a
@@ -1260,8 +1285,8 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
12601285 g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number))
12611286 }
12621287
1263 // Checks: with require_checks, the head must carry statuses and every
1264 // one of them must be green.
1288 // Checks: with require_checks, every status the head carries must be
1289 // green, and a head whose CI would report must carry some.
12651290 statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
12661291 if err != nil {
12671292 return g, err
@@ -1271,7 +1296,9 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h
12711296 switch g.Checks {
12721297 case "success":
12731298 case "":
1274 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
1299 if headRunsJobs(dir, headSHA) {
1300 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
1301 }
12751302 default:
12761303 var bad []string
12771304 for _, st := range statuses {