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