Commit 35867bdb7c
Verified · cmc ci/build: success ci/test: failure
.gitbay/wiki/API.org +4 −2
| @@ -109,8 +109,10 @@ One row per (commit, context): re-reporting updates in place. States: | ||
| 109 | 109 | worst of them. Statuses appear on commit pages, MR pages, and |
| 110 | 110 | =mr show=, each with =updated_at=; a =ci/<job>= status also carries |
| 111 | 111 | =duration=, read from the build behind it, once that build has |
| 112 | finished. With =repo settings require-checks <repo> on=, merging | |
| 113 | requires the MR head to carry statuses and all of them green. Each | |
| 112 | finished. With =repo settings require-checks <repo> on=, every status | |
| 113 | on the MR head must be green, and a head whose =.gitbay/ci.yml= has a | |
| 114 | job a push would run must carry some; a repository with no CI | |
| 115 | configuration, or only scheduled and tag jobs, merges. Each | |
| 114 | 116 | report also emits a =status= event to webhooks. |
| 115 | 117 | |
| 116 | 118 | * Webhooks |
e2e/ci_nojobs_test.go added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func 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 | ||
| 64 | func 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. | |
| 76 | func 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. | |
| 84 | func 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. | |
| 94 | func 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 ( | ||
| 9 | 9 | "strings" |
| 10 | 10 | "time" |
| 11 | 11 | |
| 12 | "gitbay.org/gitbay/internal/ci" | |
| 12 | 13 | "gitbay.org/gitbay/internal/gitutil" |
| 13 | 14 | "gitbay.org/gitbay/internal/policy" |
| 14 | 15 | "gitbay.org/gitbay/internal/protocol" |
| @@ -1243,6 +1244,30 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA | ||
| 1243 | 1244 | return -1 |
| 1244 | 1245 | } |
| 1245 | 1246 | |
| 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. | |
| 1254 | func 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 | ||
| 1246 | 1271 | // MergeGates computes where a merge request stands against its |
| 1247 | 1272 | // repository's gates: draft, require_checks, require_approvals (fresh, |
| 1248 | 1273 | // 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 | ||
| 1260 | 1285 | g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number)) |
| 1261 | 1286 | } |
| 1262 | 1287 | |
| 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. | |
| 1265 | 1290 | statuses, err := st.ListCommitStatuses(repo.ID, headSHA) |
| 1266 | 1291 | if err != nil { |
| 1267 | 1292 | return g, err |
| @@ -1271,7 +1296,9 @@ func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, h | ||
| 1271 | 1296 | switch g.Checks { |
| 1272 | 1297 | case "success": |
| 1273 | 1298 | 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 | } | |
| 1275 | 1302 | default: |
| 1276 | 1303 | var bad []string |
| 1277 | 1304 | for _, st := range statuses { |