internal/control/build_test.go
341 lines · 12835 bytes
1package control
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "gitbay.org/gitbay/internal/store"
12)
13
14const testZeroSHA = "0000000000000000000000000000000000000000"
15
16// gitTestEnv sets up an isolated git identity so tests never touch a
17// developer's real config.
18func gitTestEnv() []string {
19 return append(os.Environ(),
20 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
21 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
22 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
23}
24
25func gitRunner(t *testing.T) func(dir string, args ...string) string {
26 t.Helper()
27 env := gitTestEnv()
28 return func(dir string, args ...string) string {
29 t.Helper()
30 cmd := exec.Command("git", args...)
31 cmd.Dir = dir
32 cmd.Env = env
33 out, err := cmd.CombinedOutput()
34 if err != nil {
35 t.Fatalf("git %v: %v\n%s", args, err, out)
36 }
37 return string(out)
38 }
39}
40
41// newQueueTestRepo returns a store with one public repo (default branch
42// "main", matching the schema default) and the uid to queue builds as.
43func newQueueTestRepo(t *testing.T) (*store.Store, store.Repo, int64) {
44 t.Helper()
45 st, err := store.Open(":memory:")
46 if err != nil {
47 t.Fatal(err)
48 }
49 t.Cleanup(func() { st.Close() })
50 if err := st.MigrateUp(); err != nil {
51 t.Fatal(err)
52 }
53 uid, err := st.CreateUser("alice", false)
54 if err != nil {
55 t.Fatal(err)
56 }
57 repoID, err := st.CreateRepo("user", uid, "app", "public")
58 if err != nil {
59 t.Fatal(err)
60 }
61 repo, err := st.RepoByID(repoID)
62 if err != nil {
63 t.Fatal(err)
64 }
65 return st, repo, uid
66}
67
68// A DiffFiles failure must not turn into a silent skip: when the old sha
69// on record cannot be diffed against, every job runs regardless of what
70// it names in paths, the same as when there is no diff base at all.
71func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) {
72 st, repo, uid := newQueueTestRepo(t)
73 git := gitRunner(t)
74
75 root := t.TempDir()
76
77 // A job whose paths would exclude a docs-only change, so the test
78 // proves something: without fail-open, the diff failure would leave
79 // the filter unevaluated and this build would never queue.
80 src := filepath.Join(root, "src")
81 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
82 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
83 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
84 "jobs:\n unit:\n paths:\n - src/**\n steps:\n - echo hi\n"), 0o644)
85 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
86 git(root, "init", "-q", "-b", "main", "src")
87 git(src, "add", ".")
88 git(src, "commit", "-q", "-m", "base")
89 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
90 git(src, "add", ".")
91 git(src, "commit", "-q", "-m", "docs only")
92 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
93
94 dir := RepoDir(root, repo.OwnerName, repo.Name)
95 os.MkdirAll(filepath.Dir(dir), 0o755)
96 git(root, "clone", "-q", "--bare", src, dir)
97
98 // Well-formed but names no object in this repo: git diff itself
99 // fails, rather than the empty/all-zero short-circuit HasDiffBase
100 // already covers.
101 old := strings.Repeat("1", 40)
102
103 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", old, newSHA, time.Now())
104
105 builds, err := st.ListBuilds(repo.ID, 10)
106 if err != nil || len(builds) != 1 {
107 t.Fatalf("builds after queue: %v %v", builds, err)
108 }
109 if builds[0].Job != "unit" || builds[0].Status != "pending" || builds[0].SHA != newSHA {
110 t.Fatalf("queued build wrong: %+v", builds[0])
111 }
112}
113
114// A new branch's first push carries no old sha, but a diff base still
115// exists: the merge base with the default branch. A push whose commits
116// only touch paths a job ignores must not queue that job, or path
117// filters never do anything on the ordinary branch-then-MR workflow.
118func TestQueueBranchBuildsNewBranchIgnoredPathSkips(t *testing.T) {
119 st, repo, uid := newQueueTestRepo(t)
120 git := gitRunner(t)
121 root := t.TempDir()
122
123 src := filepath.Join(root, "src")
124 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
125 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
126 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
127 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
128 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
129 git(root, "init", "-q", "-b", "main", "src")
130 git(src, "add", ".")
131 git(src, "commit", "-q", "-m", "base")
132
133 git(src, "checkout", "-q", "-b", "feature")
134 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
135 git(src, "add", ".")
136 git(src, "commit", "-q", "-m", "docs only")
137 featureSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
138
139 dir := RepoDir(root, repo.OwnerName, repo.Name)
140 os.MkdirAll(filepath.Dir(dir), 0o755)
141 git(root, "clone", "-q", "--bare", src, dir)
142
143 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
144
145 builds, err := st.ListBuilds(repo.ID, 10)
146 if err != nil {
147 t.Fatal(err)
148 }
149 if len(builds) != 0 {
150 t.Fatalf("docs-only push on a new branch queued a build: %+v", builds)
151 }
152}
153
154// The same new-branch push, but touching a path the job cares about:
155// the merge-base diff must still let it through.
156func TestQueueBranchBuildsNewBranchMatchedPathQueues(t *testing.T) {
157 st, repo, uid := newQueueTestRepo(t)
158 git := gitRunner(t)
159 root := t.TempDir()
160
161 src := filepath.Join(root, "src")
162 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
163 os.MkdirAll(filepath.Join(src, "src"), 0o755)
164 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
165 "jobs:\n unit:\n paths:\n - src/**\n steps:\n - echo hi\n"), 0o644)
166 os.WriteFile(filepath.Join(src, "src", "x.go"), []byte("package x\n"), 0o644)
167 git(root, "init", "-q", "-b", "main", "src")
168 git(src, "add", ".")
169 git(src, "commit", "-q", "-m", "base")
170
171 git(src, "checkout", "-q", "-b", "feature")
172 os.WriteFile(filepath.Join(src, "src", "x.go"), []byte("package x\n\nvar y int\n"), 0o644)
173 git(src, "add", ".")
174 git(src, "commit", "-q", "-m", "src change")
175 featureSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
176
177 dir := RepoDir(root, repo.OwnerName, repo.Name)
178 os.MkdirAll(filepath.Dir(dir), 0o755)
179 git(root, "clone", "-q", "--bare", src, dir)
180
181 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
182
183 builds, err := st.ListBuilds(repo.ID, 10)
184 if err != nil || len(builds) != 1 {
185 t.Fatalf("builds after queue: %v %v", builds, err)
186 }
187 if builds[0].Job != "unit" || builds[0].SHA != featureSHA {
188 t.Fatalf("queued build wrong: %+v", builds[0])
189 }
190}
191
192// When the new branch shares no history with the default branch, the
193// merge base cannot be computed. That must fail open, same as any other
194// diff base that cannot be evaluated.
195func TestQueueBranchBuildsNewBranchFailsOpenWithoutMergeBase(t *testing.T) {
196 st, repo, uid := newQueueTestRepo(t)
197 git := gitRunner(t)
198 root := t.TempDir()
199
200 dir := RepoDir(root, repo.OwnerName, repo.Name)
201 os.MkdirAll(filepath.Dir(dir), 0o755)
202 git(root, "init", "-q", "--bare", dir)
203
204 mainSrc := filepath.Join(root, "main-src")
205 os.MkdirAll(filepath.Join(mainSrc, ".gitbay"), 0o755)
206 os.WriteFile(filepath.Join(mainSrc, ".gitbay", "ci.yml"), []byte(
207 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
208 git(root, "init", "-q", "-b", "main", "main-src")
209 git(mainSrc, "add", ".")
210 git(mainSrc, "commit", "-q", "-m", "base")
211 git(mainSrc, "push", "-q", dir, "main")
212
213 // An unrelated repository: no common commit with main.
214 otherSrc := filepath.Join(root, "other-src")
215 os.MkdirAll(filepath.Join(otherSrc, ".gitbay"), 0o755)
216 os.MkdirAll(filepath.Join(otherSrc, "docs"), 0o755)
217 os.WriteFile(filepath.Join(otherSrc, ".gitbay", "ci.yml"), []byte(
218 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
219 os.WriteFile(filepath.Join(otherSrc, "docs", "x.md"), []byte("# x\n"), 0o644)
220 git(root, "init", "-q", "-b", "feature", "other-src")
221 git(otherSrc, "add", ".")
222 git(otherSrc, "commit", "-q", "-m", "unrelated docs-only")
223 otherSHA := strings.TrimSpace(git(otherSrc, "rev-parse", "HEAD"))
224 git(otherSrc, "push", "-q", dir, "feature")
225
226 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, otherSHA, time.Now())
227
228 builds, err := st.ListBuilds(repo.ID, 10)
229 if err != nil || len(builds) != 1 {
230 t.Fatalf("expected fail-open to queue the job: %v %v", builds, err)
231 }
232}
233
234// The first push to a brand-new repository moves the default branch
235// itself with no prior commit: the merge base of the default branch
236// against its own tip is the tip, which carries no diff. That must
237// fail open rather than read as "nothing changed".
238func TestQueueBranchBuildsFreshDefaultBranchFailsOpen(t *testing.T) {
239 st, repo, uid := newQueueTestRepo(t)
240 git := gitRunner(t)
241 root := t.TempDir()
242
243 src := filepath.Join(root, "src")
244 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
245 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
246 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
247 "jobs:\n unit:\n paths:\n - src/**\n steps:\n - echo hi\n"), 0o644)
248 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
249 git(root, "init", "-q", "-b", "main", "src")
250 git(src, "add", ".")
251 git(src, "commit", "-q", "-m", "initial")
252 sha := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
253
254 dir := RepoDir(root, repo.OwnerName, repo.Name)
255 os.MkdirAll(filepath.Dir(dir), 0o755)
256 git(root, "clone", "-q", "--bare", src, dir)
257
258 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", testZeroSHA, sha, time.Now())
259
260 builds, err := st.ListBuilds(repo.ID, 10)
261 if err != nil || len(builds) != 1 {
262 t.Fatalf("expected fail-open on the repository's first commit: %v %v", builds, err)
263 }
264}
265
266// An ordinary push with a genuine old sha is unaffected by the
267// new-branch handling: filtering still works exactly as it did before.
268func TestQueueBranchBuildsOrdinaryPushStillFilters(t *testing.T) {
269 st, repo, uid := newQueueTestRepo(t)
270 git := gitRunner(t)
271 root := t.TempDir()
272
273 src := filepath.Join(root, "src")
274 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
275 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
276 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
277 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
278 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
279 git(root, "init", "-q", "-b", "main", "src")
280 git(src, "add", ".")
281 git(src, "commit", "-q", "-m", "base")
282 oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
283
284 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
285 git(src, "add", ".")
286 git(src, "commit", "-q", "-m", "docs only")
287 newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
288
289 dir := RepoDir(root, repo.OwnerName, repo.Name)
290 os.MkdirAll(filepath.Dir(dir), 0o755)
291 git(root, "clone", "-q", "--bare", src, dir)
292
293 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
294
295 builds, err := st.ListBuilds(repo.ID, 10)
296 if err != nil {
297 t.Fatal(err)
298 }
299 if len(builds) != 0 {
300 t.Fatalf("docs-only push with a real old sha queued a build: %+v", builds)
301 }
302}
303
304// QueueMRBuilds keeps failing open with no diff base at all: deriving a
305// merge base for the MR head is deliberately out of scope here (#172 —
306// filtering a head down to zero jobs leaves it with no statuses, which
307// the require_checks gate reads as unmergeable). This test documents
308// and locks in that choice.
309func TestQueueMRBuildsStillFailsOpen(t *testing.T) {
310 st, repo, uid := newQueueTestRepo(t)
311 git := gitRunner(t)
312 root := t.TempDir()
313
314 src := filepath.Join(root, "src")
315 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
316 os.MkdirAll(filepath.Join(src, "docs"), 0o755)
317 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
318 "jobs:\n unit:\n paths-ignore:\n - docs/**\n steps:\n - echo hi\n"), 0o644)
319 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
320 git(root, "init", "-q", "-b", "main", "src")
321 git(src, "add", ".")
322 git(src, "commit", "-q", "-m", "base")
323
324 git(src, "checkout", "-q", "-b", "pr")
325 os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
326 git(src, "add", ".")
327 git(src, "commit", "-q", "-m", "docs only")
328 prSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
329
330 dir := RepoDir(root, repo.OwnerName, repo.Name)
331 os.MkdirAll(filepath.Dir(dir), 0o755)
332 git(root, "clone", "-q", "--bare", src, dir)
333 git(dir, "update-ref", "refs/merge-requests/1/head", prSHA)
334
335 QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, prSHA)
336
337 builds, err := st.ListBuilds(repo.ID, 10)
338 if err != nil || len(builds) != 1 {
339 t.Fatalf("expected the MR head to fail open and queue a build: %v %v", builds, err)
340 }
341}