| @@ -11,15 +11,42 @@ import ( |
| 11 | "gitbay.org/gitbay/internal/store" |
11 | "gitbay.org/gitbay/internal/store" |
| 12 | ) |
12 | ) |
| 13 | |
13 | |
| 14 | // A DiffFiles failure must not turn into a silent skip: when the old sha |
14 | const testZeroSHA = "0000000000000000000000000000000000000000" |
| 15 | // on record cannot be diffed against, every job runs regardless of what |
15 | |
| 16 | // it names in paths, the same as when there is no diff base at all. |
16 | // gitTestEnv sets up an isolated git identity so tests never touch a |
| 17 | func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) { |
17 | // developer's real config. |
| |
18 | func 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 | |
| |
25 | func 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. |
| |
43 | func newQueueTestRepo(t *testing.T) (*store.Store, store.Repo, int64) { |
| |
44 | t.Helper() |
| 18 | st, err := store.Open(":memory:") |
45 | st, err := store.Open(":memory:") |
| 19 | if err != nil { |
46 | if err != nil { |
| 20 | t.Fatal(err) |
47 | t.Fatal(err) |
| 21 | } |
48 | } |
| 22 | defer st.Close() |
49 | t.Cleanup(func() { st.Close() }) |
| 23 | if err := st.MigrateUp(); err != nil { |
50 | if err := st.MigrateUp(); err != nil { |
| 24 | t.Fatal(err) |
51 | t.Fatal(err) |
| 25 | } |
52 | } |
| @@ -35,23 +62,17 @@ func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) { |
| 35 | if err != nil { |
62 | if err != nil { |
| 36 | t.Fatal(err) |
63 | t.Fatal(err) |
| 37 | } |
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. |
| |
71 | func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) { |
| |
72 | st, repo, uid := newQueueTestRepo(t) |
| |
73 | git := gitRunner(t) |
| 38 | |
74 | |
| 39 | root := t.TempDir() |
75 | root := t.TempDir() |
| 40 | env := append(os.Environ(), |
| |
| 41 | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", |
| |
| 42 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", |
| |
| 43 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test") |
| |
| 44 | git := func(dir string, args ...string) string { |
| |
| 45 | t.Helper() |
| |
| 46 | cmd := exec.Command("git", args...) |
| |
| 47 | cmd.Dir = dir |
| |
| 48 | cmd.Env = env |
| |
| 49 | out, err := cmd.CombinedOutput() |
| |
| 50 | if err != nil { |
| |
| 51 | t.Fatalf("git %v: %v\n%s", args, err, out) |
| |
| 52 | } |
| |
| 53 | return string(out) |
| |
| 54 | } |
| |
| 55 | |
76 | |
| 56 | // A job whose paths would exclude a docs-only change, so the test |
77 | // A job whose paths would exclude a docs-only change, so the test |
| 57 | // proves something: without fail-open, the diff failure would leave |
78 | // proves something: without fail-open, the diff failure would leave |
| @@ -89,3 +110,232 @@ func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) { |
| 89 | t.Fatalf("queued build wrong: %+v", builds[0]) |
110 | t.Fatalf("queued build wrong: %+v", builds[0]) |
| 90 | } |
111 | } |
| 91 | } |
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. |
| |
118 | func 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. |
| |
156 | func 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. |
| |
195 | func 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". |
| |
238 | func 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. |
| |
268 | func 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. |
| |
309 | func 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 | } |