internal/control/build.go
848 lines · 32217 bytes
1package control
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "regexp"
10 "strconv"
11 "strings"
12 "time"
13
14 "gitbay.org/gitbay/internal/ci"
15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "gitbay.org/gitbay/internal/store"
19)
20
21func init() {
22 register(Command{Path: []string{"build", "list"},
23 Summary: "list recent builds",
24 Usage: "build list <owner/name>", ReadOnly: true, Run: runBuildList})
25 register(Command{Path: []string{"build", "show"},
26 Summary: "show one build",
27 Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow})
28 register(Command{Path: []string{"build", "log"},
29 Summary: "print a build's log",
30 Usage: "build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog})
31
32 register(Command{Path: []string{"build", "jobs"},
33 Summary: "list the jobs a trigger can name",
34 Usage: "build jobs <owner/name>", ReadOnly: true, Run: runBuildJobs})
35
36 register(Command{Path: []string{"build", "cancel"},
37 Summary: "withdraw a queued build before a runner claims it",
38 Usage: "build cancel <owner/name> <n>", Run: runBuildCancel})
39 register(Command{Path: []string{"build", "trigger"},
40 Summary: "queue a job now (scheduled or not)",
41 Usage: "build trigger <owner/name> <job>", Run: runBuildTrigger})
42 // Secrets: set over stdin, listed by name only, injected into the
43 // repo's builds as environment variables. Same discipline as mirror
44 // tokens — the value never appears in argv, logs, or output.
45 register(Command{Path: []string{"repo", "secret", "set"},
46 Summary: "set a build secret",
47 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
48 ReadsStdin: true, SSHOnly: true, Run: runSecretSet})
49 register(Command{Path: []string{"repo", "secret", "remove"},
50 Summary: "remove a build secret",
51 Usage: "repo secret remove <owner/name> <NAME>", Run: runSecretRemove})
52 register(Command{Path: []string{"repo", "secret", "list"},
53 Summary: "list build secret names",
54 Usage: "repo secret list <owner/name>", ReadOnly: true, Run: runSecretList})
55
56 // Runner commands: the claim/report loop for gitbay-runner. A runner
57 // executes arbitrary repo code, so handing out jobs is the instance
58 // operator's call: a key added with --scope runner, which the
59 // dispatcher confines to these three commands and read-only git, or
60 // an admin key, which a runner host should not hold (#92).
61 register(Command{Path: []string{"runner", "next"},
62 Summary: "claim the oldest pending build this key may run (runner protocol)",
63 Usage: "runner next [--untrusted] [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
64 register(Command{Path: []string{"runner", "log"},
65 Summary: "append a build's log from stdin",
66 Usage: "runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog})
67 register(Command{Path: []string{"runner", "done"},
68 Summary: "finish a build",
69 Usage: "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone})
70}
71
72type BuildOut struct {
73 Number int64 `json:"number"`
74 Job string `json:"job"`
75 Status string `json:"status"`
76 SHA string `json:"sha"`
77 Ref string `json:"ref"`
78 CreatedAt string `json:"created_at"`
79 FinishedAt string `json:"finished_at,omitempty"`
80}
81
82func buildToOut(b store.Build) BuildOut {
83 return BuildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
84}
85
86func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
87 if len(args) != 2 {
88 return store.Repo{}, store.Build{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
89 }
90 repo, code := resolveRepo(c, args[0], policy.CanRead)
91 if code >= 0 {
92 return repo, store.Build{}, code
93 }
94 n, err := strconv.ParseInt(args[1], 10, 64)
95 if err != nil {
96 return repo, store.Build{}, c.fail(protocol.ExitUsage, "bad build number %q", args[1])
97 }
98 b, err := c.Store.BuildByNumber(repo.ID, n)
99 if err != nil {
100 return repo, b, c.fail(protocol.ExitNotFound, "no build %d on %s", n, repo.Path())
101 }
102 return repo, b, -1
103}
104
105func runBuildList(c *Ctx, args []string) int {
106 if len(args) != 1 {
107 return c.fail(protocol.ExitUsage, "usage: build list <owner/name>")
108 }
109 repo, code := resolveRepo(c, args[0], policy.CanRead)
110 if code >= 0 {
111 return code
112 }
113 builds, err := c.Store.ListBuilds(repo.ID, 50)
114 if err != nil {
115 return c.fail(protocol.ExitFailure, "%v", err)
116 }
117 var ds []BuildOut
118 for _, b := range builds {
119 ds = append(ds, buildToOut(b))
120 }
121 return c.emit(ds, func(w io.Writer) {
122 for _, d := range ds {
123 fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref)
124 }
125 })
126}
127
128func runBuildShow(c *Ctx, args []string) int {
129 _, b, code := buildRef(c, args)
130 if code >= 0 {
131 return code
132 }
133 d := buildToOut(b)
134 return c.emit(d, func(w io.Writer) {
135 fmt.Fprintf(w, "build %d\t%s\t%s\n%.10s on %s\nqueued %s", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.CreatedAt)
136 if d.FinishedAt != "" {
137 fmt.Fprintf(w, ", finished %s", d.FinishedAt)
138 }
139 fmt.Fprintln(w)
140 })
141}
142
143func runBuildLog(c *Ctx, args []string) int {
144 _, b, code := buildRef(c, args)
145 if code >= 0 {
146 return code
147 }
148 log, err := c.Store.BuildLog(b.ID)
149 if err != nil {
150 return c.fail(protocol.ExitFailure, "%v", err)
151 }
152 c.Stdout.Write(log)
153 return protocol.ExitOK
154}
155
156type JobOut struct {
157 Name string `json:"name"`
158 Schedule string `json:"schedule,omitempty"`
159 Tags string `json:"tags,omitempty"`
160}
161
162// repoJobs reads the CI config on the default branch — the same file the
163// scheduler reads — and returns its jobs with the sha they came from.
164func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) {
165 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
166 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
167 if err != nil {
168 return nil, "", c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err)
169 }
170 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
171 if err != nil {
172 return nil, "", c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch)
173 }
174 jobs, err := ci.Parse(raw)
175 if err != nil {
176 return nil, "", c.failErr(err)
177 }
178 return jobs, sha, -1
179}
180
181// runBuildJobs answers "what can I trigger?". Without it only a surface
182// that can read the repository's git could offer the choice.
183func runBuildJobs(c *Ctx, args []string) int {
184 if len(args) != 1 {
185 return c.fail(protocol.ExitUsage, "usage: build jobs <owner/name>")
186 }
187 repo, code := resolveRepo(c, args[0], policy.CanRead)
188 if code >= 0 {
189 return code
190 }
191 jobs, _, code := repoJobs(c, repo)
192 if code >= 0 {
193 return code
194 }
195 out := make([]JobOut, 0, len(jobs))
196 for _, j := range jobs {
197 out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
198 }
199 return c.emit(out, func(w io.Writer) {
200 for _, j := range out {
201 switch {
202 case j.Schedule != "":
203 fmt.Fprintf(w, "%s\tschedule %s\n", j.Name, j.Schedule)
204 case j.Tags != "":
205 fmt.Fprintf(w, "%s\ttags %s\n", j.Name, j.Tags)
206 default:
207 fmt.Fprintf(w, "%s\ton push\n", j.Name)
208 }
209 }
210 })
211}
212
213func runBuildTrigger(c *Ctx, args []string) int {
214 if len(args) != 2 {
215 return c.fail(protocol.ExitUsage, "usage: build trigger <owner/name> <job>")
216 }
217 repo, code := resolveRepo(c, args[0], policy.CanWrite)
218 if code >= 0 {
219 return code
220 }
221 jobs, sha, code := repoJobs(c, repo)
222 if code >= 0 {
223 return code
224 }
225 for _, j := range jobs {
226 if j.Name != args[1] {
227 continue
228 }
229 steps, _ := json.Marshal(j.Steps)
230 tree, _ := gitutil.ResolveTree(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), sha)
231 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps), j.Image, tree, true)
232 if err != nil {
233 return c.fail(protocol.ExitFailure, "%v", err)
234 }
235 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), n)
236 c.Store.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "triggered", url, c.User.ID)
237 return c.emit(map[string]any{"build": n, "job": j.Name, "sha": sha}, func(w io.Writer) {
238 fmt.Fprintf(w, "queued build %d (%s @ %.10s)\n", n, j.Name, sha)
239 })
240 }
241 return c.fail(protocol.ExitNotFound, "no job %q in %s", args[1], ci.ConfigPath)
242}
243
244// secretName is env-var shaped: the value lands in the build environment.
245var secretName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`)
246
247func runSecretSet(c *Ctx, args []string) int {
248 if len(args) != 2 {
249 return c.fail(protocol.ExitUsage, "usage: repo secret set <owner/name> <NAME> (value on stdin)")
250 }
251 if !secretName.MatchString(args[1]) {
252 return c.fail(protocol.ExitUsage, "secret names are env-var shaped: uppercase letters, digits, _")
253 }
254 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
255 if code >= 0 {
256 return code
257 }
258 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
259 if err != nil {
260 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
261 }
262 value := strings.TrimRight(string(raw), "\n")
263 if value == "" {
264 return c.fail(protocol.ExitUsage, "no value on stdin (pipe it: printf %%s TOKEN | ...)")
265 }
266 if err := c.Store.SetBuildSecret(repo.ID, args[1], value); err != nil {
267 return c.fail(protocol.ExitFailure, "%v", err)
268 }
269 return c.emit(map[string]string{"secret": args[1]}, func(w io.Writer) {
270 fmt.Fprintf(w, "secret %s set on %s\n", args[1], repo.Path())
271 })
272}
273
274func runSecretRemove(c *Ctx, args []string) int {
275 if len(args) != 2 {
276 return c.fail(protocol.ExitUsage, "usage: repo secret remove <owner/name> <NAME>")
277 }
278 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
279 if code >= 0 {
280 return code
281 }
282 if err := c.Store.RemoveBuildSecret(repo.ID, args[1]); err != nil {
283 if errors.Is(err, store.ErrNotFound) {
284 return c.fail(protocol.ExitNotFound, "no secret %s on %s", args[1], repo.Path())
285 }
286 return c.fail(protocol.ExitFailure, "%v", err)
287 }
288 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
289 fmt.Fprintf(w, "removed %s\n", args[1])
290 })
291}
292
293func runSecretList(c *Ctx, args []string) int {
294 if len(args) != 1 {
295 return c.fail(protocol.ExitUsage, "usage: repo secret list <owner/name>")
296 }
297 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
298 if code >= 0 {
299 return code
300 }
301 names, err := c.Store.ListBuildSecretNames(repo.ID)
302 if err != nil {
303 return c.fail(protocol.ExitFailure, "%v", err)
304 }
305 return c.emit(names, func(w io.Writer) {
306 for _, n := range names {
307 fmt.Fprintln(w, n)
308 }
309 })
310}
311
312// runnerSession resolves the key behind a runner-protocol session. The
313// runner commands are SSHOnly, so Source is the key's fingerprint. An
314// admin key is accepted so an operator can rotate at their own pace; a
315// runner host should hold a key added with --scope runner.
316func runnerSession(c *Ctx) (store.SSHKey, int) {
317 if c.Scope != "runner" && !c.User.IsAdmin {
318 return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner")
319 }
320 key, err := c.Store.SSHKeyByFingerprint(c.Source)
321 if err != nil {
322 return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need an SSH key session")
323 }
324 return key, -1
325}
326
327// runnerMayBuild reports whether a runner session may act on a
328// repository's builds: an admin user may on any, a runner key on the
329// repositories it is attached to (#184).
330func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) {
331 if c.User.IsAdmin {
332 return true, nil
333 }
334 return c.Store.RunnerAttached(key.ID, repoID)
335}
336
337// maxOrphanSkip bounds how many claimed builds runRunnerNext will find
338// unreachable and cancel in one call before giving up. Only fast-forward
339// merges are allowed here, so any branch whose target advances gets
340// rebased and force-pushed, and a stack of branches can do that repeatedly
341// in one sitting — the issue this guards saw five in an afternoon. The cap
342// is well above that, so a real backlog is never cut short, while a
343// repository whose queue is orphaned end to end still returns rather than
344// walking it forever.
345const maxOrphanSkip = 50
346
347func runRunnerNext(c *Ctx, args []string) int {
348 key, code := runnerSession(c)
349 if code >= 0 {
350 return code
351 }
352 f, err := parseFlags(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1,
353 Usage: "runner next [--untrusted] [<owner/name>...]"})
354 if err != nil {
355 return c.fail(protocol.ExitUsage, "%v", err)
356 }
357 // The candidate set. An admin key claims from any repository, narrowed
358 // by the names given. A runner key claims from the repositories it is
359 // attached to; a name outside them is refused, not ignored, so a
360 // misconfigured runner says so instead of idling.
361 var repoIDs []int64
362 for _, arg := range f.Pos {
363 repo, code := resolveRepo(c, arg, policy.CanRead)
364 if code >= 0 {
365 return code
366 }
367 ok, err := runnerMayBuild(c, key, repo.ID)
368 if err != nil {
369 return c.fail(protocol.ExitFailure, "%v", err)
370 }
371 if !ok {
372 return c.fail(protocol.ExitDenied, "this key is not attached to %s", repo.Path())
373 }
374 repoIDs = append(repoIDs, repo.ID)
375 }
376 if !c.User.IsAdmin && len(repoIDs) == 0 {
377 repoIDs, err = c.Store.RunnerRepoIDs(key.ID)
378 if err != nil {
379 return c.fail(protocol.ExitFailure, "%v", err)
380 }
381 if len(repoIDs) == 0 {
382 // Nothing attached: nothing to claim. Still a heartbeat, so
383 // admin runners shows the key polling.
384 c.Store.TouchRunner(key.ID, c.User.ID, "", 0)
385 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
386 }
387 }
388 untrusted := f.Has("--untrusted")
389 var b store.Build
390 var repo store.Repo
391 var ok bool
392 for attempt := 0; attempt < maxOrphanSkip; attempt++ {
393 b, ok, err = c.Store.ClaimBuild(repoIDs, untrusted)
394 if err != nil {
395 return c.fail(protocol.ExitFailure, "%v", err)
396 }
397 if !ok {
398 break
399 }
400 repo, err = c.Store.RepoByID(b.RepoID)
401 if err != nil {
402 return c.fail(protocol.ExitFailure, "%v", err)
403 }
404 // Only fast-forward merges are allowed here, so a target that
405 // advances gets rebased and force-pushed, orphaning whatever was
406 // queued for the old head: the runner would clone the repo and
407 // fail at checkout with a git internal error that reads exactly
408 // like a real failure. Catch it here instead. A check that itself
409 // fails is not evidence of anything — the build runs for real and
410 // is left to fail on its own terms, never cancelled on a guess.
411 reachable, err := gitutil.Reachable(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), b.SHA)
412 if err != nil || reachable {
413 break
414 }
415 if code := cancelOrphanedBuild(c, repo, b); code >= 0 {
416 return code
417 }
418 // Cancelled, not claimed: if the cap is hit right here, the runner
419 // heartbeat below must not record this build as the one handed out.
420 b, ok = store.Build{}, false
421 }
422 // The poll itself is the runner's heartbeat: admin runners reads it.
423 c.Store.TouchRunner(key.ID, c.User.ID, strings.Join(f.Pos, ","), b.ID)
424 if !ok {
425 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
426 }
427 var steps []string
428 json.Unmarshal([]byte(b.Steps), &steps)
429 // Secrets ride the claim: this channel is admin-only and the values
430 // land in the build's environment, nowhere else.
431 var secrets map[string]string
432 if b.Trusted {
433 secrets, err = c.Store.BuildSecrets(b.RepoID)
434 if err != nil {
435 return c.fail(protocol.ExitFailure, "%v", err)
436 }
437 }
438 d := struct {
439 ID int64 `json:"id"`
440 Repo string `json:"repo"`
441 Number int64 `json:"number"`
442 Job string `json:"job"`
443 SHA string `json:"sha"`
444 Ref string `json:"ref"`
445 Steps []string `json:"steps"`
446 Image string `json:"image,omitempty"`
447 Secrets map[string]string `json:"secrets,omitempty"`
448 }{b.ID, repo.Path(), b.Number, b.Job, b.SHA, b.Ref, steps, b.Image, secrets}
449 return c.emit(d, func(w io.Writer) {
450 fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
451 })
452}
453
454func runRunnerLog(c *Ctx, args []string) int {
455 key, code := runnerSession(c)
456 if code >= 0 {
457 return code
458 }
459 if len(args) != 1 {
460 return c.fail(protocol.ExitUsage, "usage: runner log <build-id> (chunk on stdin)")
461 }
462 id, err := strconv.ParseInt(args[0], 10, 64)
463 if err != nil {
464 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
465 }
466 if b, err := c.Store.BuildByID(id); err != nil {
467 return c.fail(protocol.ExitNotFound, "no build %d", id)
468 } else if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
469 return c.fail(protocol.ExitFailure, "%v", err)
470 } else if !ok {
471 return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository")
472 }
473 // Stream stdin into the log in chunks so long builds appear live. An
474 // append that fails drops its chunk and the loop keeps draining: ending
475 // the session here breaks the runner's pipe, and a broken pipe is how a
476 // transient SQLITE_BUSY used to fail the build the log belonged to.
477 //
478 // The session is also how a running build is cancelled: while it is
479 // open the build's row is watched, and when the row stops saying
480 // running the session ends with ExitNotFound, which the runner reads as
481 // "stop this build". Any other end of the session is a lost stream.
482 type chunk struct {
483 data []byte
484 err error
485 }
486 chunks := make(chan chunk, 4)
487 go func() {
488 buf := make([]byte, 64<<10)
489 for {
490 n, rerr := c.Stdin.Read(buf)
491 if n > 0 {
492 chunks <- chunk{data: append([]byte(nil), buf[:n]...)}
493 }
494 if rerr != nil {
495 chunks <- chunk{err: rerr}
496 return
497 }
498 }
499 }()
500 watch := time.NewTicker(2 * time.Second)
501 defer watch.Stop()
502 dropped := 0
503 for {
504 select {
505 case ch := <-chunks:
506 if len(ch.data) > 0 {
507 if err := c.Store.AppendBuildLog(id, ch.data); err != nil {
508 dropped++
509 slog.Warn("appending build log", "build", id, "err", err)
510 }
511 }
512 if ch.err != nil {
513 if dropped > 0 {
514 slog.Warn("build log incomplete", "build", id, "dropped_chunks", dropped)
515 }
516 // The stream ending is the last thing the server hears
517 // from a runner that is about to die; note the time so
518 // the scheduler can fail the build if no outcome follows.
519 if err := c.Store.MarkBuildLogClosed(id); err != nil {
520 slog.Warn("marking build log closed", "build", id, "err", err)
521 }
522 return c.emit(map[string]string{"log": "ok"}, func(w io.Writer) {})
523 }
524 case <-watch.C:
525 if b, err := c.Store.BuildByID(id); err == nil && b.Status != "running" {
526 return c.fail(protocol.ExitNotFound, "build %d is %s; stop", id, b.Status)
527 }
528 }
529 }
530}
531
532func runRunnerDone(c *Ctx, args []string) int {
533 key, code := runnerSession(c)
534 if code >= 0 {
535 return code
536 }
537 if len(args) != 2 || (args[1] != "success" && args[1] != "failure") {
538 return c.fail(protocol.ExitUsage, "usage: runner done <build-id> success|failure")
539 }
540 id, err := strconv.ParseInt(args[0], 10, 64)
541 if err != nil {
542 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
543 }
544 b, err := c.Store.BuildByID(id)
545 if err != nil {
546 return c.fail(protocol.ExitNotFound, "no build %d", id)
547 }
548 if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
549 return c.fail(protocol.ExitFailure, "%v", err)
550 } else if !ok {
551 return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository")
552 }
553 // Cancelled underneath the runner: its report is late, not wrong.
554 // The row, the status and the log were settled by the cancel.
555 if b.Status == "cancelled" {
556 c.Store.RunnerDone(key.ID)
557 return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) {
558 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
559 })
560 }
561 if err := c.Store.FinishBuild(id, args[1]); err != nil {
562 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
563 }
564 c.Store.RunnerDone(key.ID)
565 repo, err := c.Store.RepoByID(b.RepoID)
566 if err != nil {
567 return c.fail(protocol.ExitFailure, "%v", err)
568 }
569 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
570 desc := "build " + args[1]
571 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil {
572 return c.fail(protocol.ExitFailure, "%v", err)
573 }
574 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
575 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
576 // A red build mails the repo's notify targets with the log tail — a
577 // failed scheduled job must not wait to be noticed.
578 if args[1] == "failure" {
579 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
580 tail := ""
581 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
582 if len(log) > 2000 {
583 log = log[len(log)-2000:]
584 }
585 tail = string(log)
586 }
587 notify(c, targets, notice{repo: repo, kind: "build",
588 subject: fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
589 action: fmt.Sprintf("build %d failed: %s on %s", b.Number, b.Job, b.Ref),
590 body: fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url),
591 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)})
592 }
593 }
594 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
595 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
596 })
597}
598
599// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
600// build per push job, with a pending commit status the runner resolves.
601// A broken config surfaces as a failed "ci/config" status, not silence.
602//
603// Both paths that move a branch call this: post-receive for a push, and
604// the merge path for a merge, which updates the ref directly and so never
605// reaches a hook. old is the branch's sha before this update, the diff
606// base a job's path filters run against; a new branch has no prior
607// commit and sends old as empty or all zeros. queueJobs falls back to
608// the merge base with the default branch in that case, so a filter
609// still applies to a branch's first push — the shape most changes have,
610// since branch-then-MR is the normal workflow here.
611func QueueBranchBuilds(
612 st *store.Store, root, siteURL string,
613 repo store.Repo, userID int64, branch, old, sha string, now time.Time,
614) {
615 queueJobs(st, root, siteURL, repo, userID, branch, old, sha, now, true, branch == repo.DefaultBranch, true)
616}
617
618// QueueMRBuilds queues the push jobs for a merge request head fetched
619// from another repository, which the target holds at
620// refs/merge-requests/<n>/head, so a fork's merge request has ci/<job>
621// statuses for require-checks to gate on (#98). The head is untrusted:
622// its build runs without the target's secrets. A same-repository head is
623// the branch push's job and is not queued here; a failed one is rebuilt
624// when it lands, not when it is proposed.
625func QueueMRBuilds(
626 st *store.Store, root, siteURL string,
627 repo store.Repo, userID, n int64, sha string,
628) {
629 // No old sha, and unlike QueueBranchBuilds, no merge-base fallback
630 // either: this deliberately keeps failing open and running every
631 // job. require_checks refuses a merge when an MR head has no
632 // statuses at all (mr.go), so filtering a head down to zero jobs
633 // would make it unmergeable rather than just unfiltered (#172).
634 queueJobs(st, root, siteURL, repo, userID, mrHeadRef(n), "", sha, time.Now(), false, false, false)
635}
636
637// skipReason names why a job's path filters excluded this push, mirroring
638// the order ci.Selected checks them in: an unmatched paths list rules a
639// job out before paths-ignore is even considered.
640func skipReason(j ci.Job, changed []string) string {
641 if len(j.Paths) > 0 {
642 hit := false
643 for _, f := range changed {
644 for _, p := range j.Paths {
645 if ci.Match(p, f) {
646 hit = true
647 }
648 }
649 }
650 if !hit {
651 return "no changed file matches paths"
652 }
653 }
654 return "every changed file matched paths-ignore"
655}
656
657func queueJobs(
658 st *store.Store, root, siteURL string,
659 repo store.Repo, userID int64, ref, old, sha string, now time.Time,
660 trusted, syncSchedules, deriveMergeBase bool,
661) {
662 dir := RepoDir(root, repo.OwnerName, repo.Name)
663 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
664 if err != nil {
665 return // no CI config at this commit
666 }
667 jobs, err := ci.Parse(raw)
668 if err != nil {
669 st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
670 return
671 }
672 // A build is a fact about a commit, not a ref: a job has no branch
673 // filter, so a commit that already passed a job on another branch has
674 // nothing left to prove when a fast-forward lands it here, and one
675 // still queued or running there will say soon enough. A failed,
676 // abandoned or cancelled build does not count; that commit runs again.
677 built, err := st.BuildsForCommit(repo.ID, sha)
678 if err != nil {
679 built = nil
680 }
681 // A job's result is a property of the tree, not the commit: a rebase
682 // onto a base that touched nothing the branch did gives every commit
683 // a new sha and the same tree, and re-running the suite over it
684 // proves nothing it did not already prove (#177). A success recorded
685 // against the tree stands for the new commit.
686 tree, _ := gitutil.ResolveTree(dir, sha)
687 // The changed-file list a job's path filters run against, computed
688 // once and only if some job actually declares one. When the diff
689 // base does not exist or the diff itself fails, filtered stays
690 // false and every job runs: a filter that cannot be evaluated must
691 // not silently skip CI.
692 //
693 // A branch's first push has no old sha, but a diff base still
694 // exists: the merge base with the default branch. Without deriving
695 // one, every job runs on every new branch, and since branch-then-MR
696 // is the normal workflow, that is the push path filters matter most
697 // for. The merge base of the default branch's tip with itself is
698 // the tip, carrying no diff — that covers the default branch's own
699 // first push on a fresh repository, and must fail open rather than
700 // read as "nothing changed".
701 filtered := false
702 var changed []string
703 for _, j := range jobs {
704 if len(j.Paths) == 0 && len(j.PathsIgnore) == 0 {
705 continue
706 }
707 diffOld := old
708 // A force-push rewrote the branch, so the old tip is not an
709 // ancestor of the new one and old..new is not "what this push
710 // changed" — it is the difference between two histories. After a
711 // rebase that is whatever the new base added, typically nothing
712 // the branch itself touched, so every path filter concludes its
713 // job is unnecessary and the branch reads as green without its
714 // suite having run (#176). The merge base is the honest base:
715 // the filter is deciding about the branch's relationship to its
716 // target, which is what the merge base expresses.
717 if ci.HasDiffBase(diffOld) && deriveMergeBase {
718 if ok, err := gitutil.IsAncestor(dir, diffOld, sha); err != nil || !ok {
719 diffOld = ""
720 }
721 }
722 if !ci.HasDiffBase(diffOld) && deriveMergeBase {
723 if base, err := gitutil.MergeBase(dir, "refs/heads/"+repo.DefaultBranch, sha); err == nil && base != sha {
724 diffOld = base
725 }
726 }
727 if ci.HasDiffBase(diffOld) {
728 if files, err := gitutil.DiffFiles(dir, diffOld, sha); err == nil {
729 changed, filtered = files, true
730 }
731 }
732 break
733 }
734 var schedules []store.Schedule
735 for _, j := range jobs {
736 // Tag jobs run on matching tag pushes only.
737 if j.Tags != "" {
738 continue
739 }
740 if b, ok := built[j.Name]; ok && (b.Status == "success" || b.Status == "pending" || b.Status == "running") {
741 continue
742 }
743 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name); ok && prev.SHA != sha {
744 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), prev.Number)
745 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "success",
746 fmt.Sprintf("passed in build %d as %.10s, same tree", prev.Number, prev.SHA), url, userID)
747 continue
748 }
749 // Scheduled jobs run on their cron, not on push; a default-branch
750 // push (re)registers them.
751 if j.Schedule != "" {
752 if syncSchedules {
753 schedules = append(schedules, store.Schedule{
754 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
755 NextRun: ci.NextRun(j.Schedule, now),
756 })
757 }
758 continue
759 }
760 // A filter that excludes this push is not silence: it satisfies
761 // require_checks with a skipped status instead of leaving the
762 // commit with none at all, which the gate refuses outright (#172).
763 if filtered && !ci.Selected(j, changed) {
764 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "skipped", skipReason(j, changed), "", userID)
765 continue
766 }
767 steps, _ := json.Marshal(j.Steps)
768 n, err := st.CreateBuild(repo.ID, j.Name, sha, ref, string(steps), j.Image, tree, trusted)
769 if err != nil {
770 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
771 continue
772 }
773 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), n)
774 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
775 }
776 if syncSchedules {
777 if err := st.SyncSchedules(repo.ID, schedules); err != nil {
778 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
779 }
780 }
781}
782
783// resolveCancelledCommitStatus sets the commit status for a build that was
784// just cancelled: if the commit already passed this job on another ref,
785// that result stands again; otherwise the context reports the
786// cancellation as an error, so the queued status left behind is never
787// pending forever.
788func resolveCancelledCommitStatus(c *Ctx, repo store.Repo, b store.Build) {
789 if prev, ok, err := c.Store.SuccessBuildFor(repo.ID, b.SHA, b.Job); err == nil && ok {
790 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
791 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
792 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
793 return
794 }
795 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
796 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "error", "cancelled", url, c.User.ID)
797}
798
799// cancelOrphanedBuild withdraws a build runRunnerNext claimed and then
800// found unreachable. It leaves the same shape behind as a build cancel a
801// person runs by hand: CancelBuild's status, a log line saying why, and
802// the commit status resolved rather than left pending. Returns -1 to mean
803// "handled, keep going"; anything else is the exit code to return.
804func cancelOrphanedBuild(c *Ctx, repo store.Repo, b store.Build) int {
805 if err := c.Store.CancelBuild(b.ID); err != nil {
806 return c.fail(protocol.ExitFailure, "%v", err)
807 }
808 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf(
809 "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA)))
810 resolveCancelledCommitStatus(c, repo, b)
811 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
812 return -1
813}
814
815func runBuildCancel(c *Ctx, args []string) int {
816 repo, b, code := buildRef(c, args)
817 if code >= 0 {
818 return code
819 }
820 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
821 if err != nil {
822 return c.fail(protocol.ExitFailure, "%v", err)
823 }
824 if !policy.CanWrite(c.User, repo, grant) {
825 return c.fail(protocol.ExitDenied, "cancelling a build needs write access to %s", repo.Path())
826 }
827 if b.Status != "pending" && b.Status != "running" {
828 return c.fail(protocol.ExitUsage, "build %d is %s; only a queued or running build can be cancelled", b.Number, b.Status)
829 }
830 if err := c.Store.CancelBuild(b.ID); err != nil {
831 return c.fail(protocol.ExitFailure, "%v", err)
832 }
833 if b.Status == "running" {
834 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("\ncancelled by %s while running; the runner stops at its next check\n", c.User.Username)))
835 } else {
836 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("cancelled by %s before a runner claimed it\n", c.User.Username)))
837 }
838 // The queued status replaced whatever the commit had for this job.
839 resolveCancelledCommitStatus(c, repo, b)
840 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
841 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) {
842 if b.Status == "running" {
843 fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job)
844 return
845 }
846 fmt.Fprintf(w, "cancelled %s build %d (%s)\n", repo.Path(), b.Number, b.Job)
847 })
848}