internal/control/build.go
1142 lines · 42879 bytes
1package control
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net"
10 "regexp"
11 "slices"
12 "strconv"
13 "strings"
14 "time"
15
16 "gitbay.org/gitbay/internal/ci"
17 "gitbay.org/gitbay/internal/gitutil"
18 "gitbay.org/gitbay/internal/policy"
19 "gitbay.org/gitbay/internal/protocol"
20 "gitbay.org/gitbay/internal/store"
21)
22
23func init() {
24 register(Command{Path: []string{"build", "list"},
25 Summary: "list recent builds",
26 Usage: "build list <owner/name> [--ref <branch>] [--status <state>] [--job <name>] [--limit <n>] [--cursor <c>]",
27 Flags: []Flag{
28 {"--ref", "<branch>", "only builds on this branch", ""},
29 {"--status", "<state>", "only builds in this state", ""},
30 {"--job", "<name>", "only this job", ""},
31 {"--limit", "<n>", "rows per page", "50"},
32 {"--cursor", "<c>", "continue from the previous page", ""},
33 },
34 Examples: []string{"build list krz/gitbay --status failure"},
35 ReadOnly: true, Run: runBuildList})
36 register(Command{Path: []string{"build", "show"},
37 Summary: "show one build",
38 Usage: "build show <owner/name> <n>",
39 Examples: []string{"build show krz/gitbay 431"},
40 ReadOnly: true, Run: runBuildShow})
41 register(Command{Path: []string{"build", "log"},
42 Summary: "print a build's log, or follow it until the build ends",
43 Usage: "build log <owner/name> <n> [--follow] [--step <step>|failed] [--tail <lines>]",
44 Flags: []Flag{
45 {"--follow", "", "stream the log until the build ends", ""},
46 {"--step", "<step>|failed", "only one step's output: 0 for the setup, a step number, or the one that failed", ""},
47 {"--tail", "<lines>", "only the last lines", ""},
48 },
49 Examples: []string{"build log krz/gitbay 431 --follow", "build log krz/gitbay 431 --step failed --tail 40"},
50 ReadOnly: true, Run: runBuildLog})
51
52 register(Command{Path: []string{"build", "jobs"},
53 Summary: "list the jobs a trigger can name",
54 Usage: "build jobs <owner/name>",
55 Examples: []string{"build jobs krz/gitbay"},
56 ReadOnly: true, Run: runBuildJobs})
57
58 register(Command{Path: []string{"build", "cancel"},
59 Summary: "withdraw a queued build before a runner claims it",
60 Usage: "build cancel <owner/name> <n>",
61 Examples: []string{"build cancel krz/gitbay 431"},
62 Run: runBuildCancel})
63 register(Command{Path: []string{"build", "trigger"},
64 Summary: "queue a job now (scheduled or not)",
65 Usage: "build trigger <owner/name> <job>",
66 Examples: []string{"build trigger krz/gitbay vuln"},
67 Run: runBuildTrigger})
68 // Secrets: set over stdin, listed by name only, injected into the
69 // repo's builds as environment variables. Same discipline as mirror
70 // tokens — the value never appears in argv, logs, or output.
71 register(Command{Path: []string{"repo", "secret", "set"},
72 NeedsRecentSignIn: true,
73 Summary: "set a build secret",
74 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
75 Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"},
76 ReadsStdin: true, Run: runSecretSet})
77 register(Command{Path: []string{"repo", "secret", "remove"},
78 Summary: "remove a build secret",
79 Usage: "repo secret remove <owner/name> <NAME>",
80 Examples: []string{"repo secret remove krz/gitbay DEPLOY_TOKEN"},
81 Run: runSecretRemove})
82 register(Command{Path: []string{"repo", "secret", "list"},
83 Summary: "list build secret names",
84 Usage: "repo secret list <owner/name>",
85 Examples: []string{"repo secret list krz/gitbay"},
86 ReadOnly: true, Run: runSecretList})
87
88 // Runner commands: the claim/report loop for gitbay-runner. A runner
89 // executes arbitrary repo code, so handing out jobs is the instance
90 // operator's call: a key added with --scope runner, which the
91 // dispatcher confines to these three commands and read-only git, or
92 // an admin key, which a runner host should not hold (#92).
93 register(Command{Path: []string{"runner", "next"},
94 Summary: "claim the oldest pending build this key may run (runner protocol)",
95 Usage: "runner next [--untrusted] [<owner/name>...]",
96 Flags: []Flag{
97 {"--untrusted", "", "this runner may build a fork's merge request head", ""},
98 },
99 Examples: []string{"runner next krz/gitbay"},
100 Run: runRunnerNext})
101 register(Command{Path: []string{"runner", "log"},
102 Summary: "append a build's log from stdin",
103 Usage: "runner log <build-id>",
104 Examples: []string{"runner log 431"},
105 ReadsStdin: true, Run: runRunnerLog})
106 register(Command{Path: []string{"runner", "done"},
107 Summary: "finish a build",
108 Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]",
109 Flags: []Flag{
110 {"--step", "<n>", "the 1-based step a failed build stopped at", ""},
111 {"--reason", "<text>", "how it failed, one line", ""},
112 },
113 Examples: []string{"runner done 431 success", "runner done 431 failure --step 3 --reason 'exit 1'"},
114 Run: runRunnerDone})
115}
116
117type BuildOut struct {
118 Number int64 `json:"number"`
119 Job string `json:"job"`
120 Status string `json:"status"`
121 SHA string `json:"sha"`
122 Ref string `json:"ref"`
123 CreatedAt string `json:"created_at"`
124 FinishedAt string `json:"finished_at,omitempty"`
125 // Subject is the first line of the commit's message, so a build
126 // names what it ran on rather than only its sha (#241). It is empty
127 // when the commit is no longer in the repository.
128 Subject string `json:"subject,omitempty"`
129 // FailedStep is the 1-based step a failed build stopped at, 0 when
130 // none; FailedReason says how ("exit 1") (#266).
131 FailedStep int `json:"failed_step,omitempty"`
132 FailedReason string `json:"failed_reason,omitempty"`
133 // DurationS is how long the build ran, once it has a start and a
134 // finish.
135 DurationS int64 `json:"duration_s,omitempty"`
136 // Steps are the job's commands; build show only.
137 Steps []string `json:"steps,omitempty"`
138}
139
140func buildToOut(b store.Build) BuildOut {
141 return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA,
142 Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt,
143 FailedStep: b.FailedStep, FailedReason: b.FailedReason,
144 DurationS: int64(b.Elapsed() / time.Second)}
145}
146
147func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
148 if len(args) != 2 {
149 return store.Repo{}, store.Build{}, c.usageWith("expected <owner/name> <number>")
150 }
151 repo, code := resolveRepo(c, args[0], policy.CanRead)
152 if code >= 0 {
153 return repo, store.Build{}, code
154 }
155 n, err := strconv.ParseInt(args[1], 10, 64)
156 if err != nil {
157 return repo, store.Build{}, c.fail(protocol.ExitUsage, "bad build number %q", args[1])
158 }
159 b, err := c.Store.BuildByNumber(repo.ID, n)
160 if err != nil {
161 return repo, b, c.fail(protocol.ExitNotFound, "no build %d on %s", n, repo.Path())
162 }
163 return repo, b, -1
164}
165
166// buildStatuses is the vocabulary --status accepts, and what a bad value
167// is told to pick from.
168var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
169
170// buildPage is how many builds one page of build list returns when no
171// --limit is given. The cap has always been there; what it is now
172// reachable past, with --cursor (#244).
173const buildPage = 50
174
175func runBuildList(c *Ctx, args []string) int {
176 args, p, code := parsePageFlags(c, args, "build", true)
177 if code >= 0 {
178 return code
179 }
180 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage})
181 if err != nil {
182 return c.fail(protocol.ExitUsage, "%v", err)
183 }
184 path := f.pos(0)
185 if path == "" {
186 return c.usage()
187 }
188 status := f.Value("--status")
189 if f.Has("--status") && !slices.Contains(buildStatuses, status) {
190 return c.fail(protocol.ExitUsage, "--status must be one of %s", strings.Join(buildStatuses, ", "))
191 }
192 repo, code := resolveRepo(c, path, policy.CanRead)
193 if code >= 0 {
194 return code
195 }
196 limit := p.queryLimit()
197 if limit == 0 {
198 limit = buildPage
199 }
200 filter := store.BuildFilter{Ref: f.Value("--ref"), Status: status, Job: f.Value("--job"), Before: p.keyInt()}
201 builds, err := c.Store.ListBuilds(repo.ID, filter, limit)
202 if err != nil {
203 return c.fail(protocol.ExitFailure, "%v", err)
204 }
205 builds, next := trimPage(p, builds, "build", func(b store.Build) string {
206 return strconv.FormatInt(b.Number, 10)
207 })
208 var ds []BuildOut
209 for _, b := range builds {
210 ds = append(ds, buildToOut(b))
211 }
212 subjects := buildSubjects(c, repo, ds)
213 for i := range ds {
214 ds[i].Subject = subjects[ds[i].SHA]
215 }
216 return c.emitPage(p, ds, next, func(w io.Writer) {
217 tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE")
218 for _, d := range ds {
219 tb.row(cLink(fmt.Sprintf("%d", d.Number), c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10))), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject))
220 }
221 tb.flush()
222 })
223}
224
225// buildSubjects reads the commit subject of each distinct sha on a page
226// of builds. Several jobs of one push share a commit, so the set is
227// usually far smaller than the page.
228func buildSubjects(c *Ctx, repo store.Repo, ds []BuildOut) map[string]string {
229 seen := map[string]bool{}
230 var shas []string
231 for _, d := range ds {
232 if d.SHA != "" && !seen[d.SHA] {
233 seen[d.SHA] = true
234 shas = append(shas, d.SHA)
235 }
236 }
237 return gitutil.Subjects(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), shas)
238}
239
240func runBuildShow(c *Ctx, args []string) int {
241 repo, b, code := buildRef(c, args)
242 if code >= 0 {
243 return code
244 }
245 d := buildToOut(b)
246 json.Unmarshal([]byte(b.Steps), &d.Steps)
247 return c.emit(d, func(w io.Writer) {
248 failedStep, failed := "", ""
249 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) {
250 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n")
251 failedStep = fmt.Sprintf("%d/%d %s", d.FailedStep, len(d.Steps), step)
252 if d.FailedReason != "" {
253 failedStep += " (" + d.FailedReason + ")"
254 }
255 } else {
256 failed = d.FailedReason
257 }
258 duration := ""
259 if d.DurationS > 0 {
260 duration = (time.Duration(d.DurationS) * time.Second).String()
261 }
262 // At a terminal: the open merge request the build ran for, and
263 // the command that prints its log.
264 mr, logCmd := "", ""
265 if c.Term.Cols > 0 {
266 if m, ok, err := c.Store.OpenMRBySource(repo.ID, d.Ref); err == nil && ok {
267 mr = fmt.Sprintf("!%d %s", m.Number, m.Title)
268 }
269 logCmd = fmt.Sprintf("gitbay build log %s %d", repo.Path(), d.Number)
270 }
271 v := c.view(w)
272 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
273 v.fields(
274 "sha", fmt.Sprintf("%.10s", d.SHA),
275 "ref", d.Ref,
276 "queued", c.when(d.CreatedAt),
277 "finished", c.when(d.FinishedAt),
278 "duration", duration,
279 "failed step", failedStep,
280 "failed", failed,
281 "mr", mr,
282 "log", logCmd,
283 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
284 )
285 if c.Term.Cols > 0 && len(d.Steps) > 0 {
286 v.section("steps")
287 tb := c.table(w, "#", "STEP", "STATE")
288 for i, step := range d.Steps {
289 line, _, _ := strings.Cut(step, "\n")
290 tb.row(cNum(int64(i+1)), cFlex(line), cState(stepState(d.Status, d.FailedStep, i+1)))
291 }
292 tb.flush()
293 }
294 })
295}
296
297// stepState is what a finished build says about one of its steps: those
298// before the failed step passed, the failed one failed, the rest never
299// ran. A build still running, or one that failed outside its steps,
300// says nothing per step.
301func stepState(status string, failedStep, n int) string {
302 switch {
303 case status == "success":
304 return "success"
305 case status != "failure" || failedStep == 0:
306 return ""
307 case n < failedStep:
308 return "success"
309 case n == failedStep:
310 return "failure"
311 }
312 return "skipped"
313}
314
315func runBuildLog(c *Ctx, args []string) int {
316 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, Values: []string{"--step", "--tail"}, MaxPos: 2, Usage: c.Cmd.Usage})
317 if err != nil {
318 return c.fail(protocol.ExitUsage, "%v", err)
319 }
320 repo, b, code := buildRef(c, f.Pos)
321 if code >= 0 {
322 return code
323 }
324 if f.Has("--follow") {
325 if f.Has("--step") || f.Has("--tail") {
326 return c.fail(protocol.ExitUsage, "--step and --tail read the stored log; drop --follow")
327 }
328 return followBuildLog(c, repo, b)
329 }
330 tail := 0
331 if f.Has("--tail") {
332 if tail, err = strconv.Atoi(f.Value("--tail")); err != nil || tail < 1 {
333 return c.fail(protocol.ExitUsage, "--tail takes a number of lines, 1 or more")
334 }
335 }
336 log, err := c.Store.BuildLog(b.ID)
337 if err != nil {
338 return c.fail(protocol.ExitFailure, "%v", err)
339 }
340 var steps []string
341 json.Unmarshal([]byte(b.Steps), &steps)
342 sections := SplitBuildLog(string(log), steps)
343 failed := ""
344 if at := FailedSection(sections, b.Status, b.FailedStep); at >= 0 {
345 failed = sections[at].Step
346 }
347 if f.Has("--step") {
348 at := -1
349 if want := f.Value("--step"); want == "failed" {
350 if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 {
351 return c.fail(protocol.ExitNotFound, "build %d did not fail", b.Number)
352 }
353 } else {
354 n, err := strconv.Atoi(want)
355 if err != nil || n < 0 || n > len(steps) {
356 return c.fail(protocol.ExitUsage, "--step takes 0 (the setup) to %d, or failed", len(steps))
357 }
358 for i, s := range sections {
359 if s.N == n {
360 at = i
361 }
362 }
363 if at < 0 {
364 return c.fail(protocol.ExitNotFound, "build %d has no output for step %d", b.Number, n)
365 }
366 }
367 log = []byte(sections[at].Text)
368 }
369 if tail > 0 {
370 log = tailLines(log, tail)
371 }
372 if c.Term.Cols > 0 {
373 log = []byte(c.Term.buildLog(string(log), failed))
374 }
375 c.Stdout.Write(log)
376 return protocol.ExitOK
377}
378
379type JobOut struct {
380 Name string `json:"name"`
381 Schedule string `json:"schedule,omitempty"`
382 Tags string `json:"tags,omitempty"`
383}
384
385// repoJobs reads the CI config on the default branch — the same file the
386// scheduler reads — and returns its jobs with the sha they came from.
387func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) {
388 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
389 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
390 if err != nil {
391 return nil, "", c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err)
392 }
393 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
394 if err != nil {
395 return nil, "", c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch)
396 }
397 jobs, err := ci.Parse(raw)
398 if err != nil {
399 return nil, "", c.failErr(err)
400 }
401 return jobs, sha, -1
402}
403
404// runBuildJobs answers "what can I trigger?". Without it only a surface
405// that can read the repository's git could offer the choice.
406func runBuildJobs(c *Ctx, args []string) int {
407 if len(args) != 1 {
408 return c.usage()
409 }
410 repo, code := resolveRepo(c, args[0], policy.CanRead)
411 if code >= 0 {
412 return code
413 }
414 jobs, _, code := repoJobs(c, repo)
415 if code >= 0 {
416 return code
417 }
418 out := make([]JobOut, 0, len(jobs))
419 for _, j := range jobs {
420 out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
421 }
422 return c.emit(out, func(w io.Writer) {
423 tb := c.table(w, "NAME", "WHEN")
424 for _, j := range out {
425 when := "on push"
426 switch {
427 case j.Schedule != "":
428 when = "schedule " + j.Schedule
429 case j.Tags != "":
430 when = "tags " + j.Tags
431 }
432 tb.row(cRef(j.Name), cText(when))
433 }
434 tb.flush()
435 })
436}
437
438func runBuildTrigger(c *Ctx, args []string) int {
439 if len(args) != 2 {
440 return c.usage()
441 }
442 repo, code := resolveRepo(c, args[0], policy.CanWrite)
443 if code >= 0 {
444 return code
445 }
446 jobs, sha, code := repoJobs(c, repo)
447 if code >= 0 {
448 return code
449 }
450 for _, j := range jobs {
451 if j.Name != args[1] {
452 continue
453 }
454 steps, _ := json.Marshal(j.Steps)
455 tree, _ := gitutil.ResolveTree(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), sha)
456 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps), j.Image, tree, true)
457 if err != nil {
458 return c.fail(protocol.ExitFailure, "%v", err)
459 }
460 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), n)
461 c.Store.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "triggered", url, c.User.ID)
462 return c.emit(map[string]any{"build": n, "job": j.Name, "sha": sha}, func(w io.Writer) {
463 fmt.Fprintf(w, "queued build %d (%s @ %.10s)\n", n, j.Name, sha)
464 })
465 }
466 return c.fail(protocol.ExitNotFound, "no job %q in %s", args[1], ci.ConfigPath)
467}
468
469// secretName is env-var shaped: the value lands in the build environment.
470var secretName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`)
471
472func runSecretSet(c *Ctx, args []string) int {
473 if len(args) != 2 {
474 return c.usage()
475 }
476 if !secretName.MatchString(args[1]) {
477 return c.fail(protocol.ExitUsage, "secret names are env-var shaped: uppercase letters, digits, _")
478 }
479 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
480 if code >= 0 {
481 return code
482 }
483 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
484 if err != nil {
485 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
486 }
487 value := strings.TrimRight(string(raw), "\n")
488 if value == "" {
489 return c.fail(protocol.ExitUsage, "no value on stdin (pipe it: printf %%s TOKEN | ...)")
490 }
491 if err := c.Store.SetBuildSecret(repo.ID, args[1], value); err != nil {
492 return c.fail(protocol.ExitFailure, "%v", err)
493 }
494 return c.emit(map[string]string{"secret": args[1]}, func(w io.Writer) {
495 fmt.Fprintf(w, "secret %s set on %s\n", args[1], repo.Path())
496 })
497}
498
499func runSecretRemove(c *Ctx, args []string) int {
500 if len(args) != 2 {
501 return c.usage()
502 }
503 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
504 if code >= 0 {
505 return code
506 }
507 if err := c.Store.RemoveBuildSecret(repo.ID, args[1]); err != nil {
508 if errors.Is(err, store.ErrNotFound) {
509 return c.fail(protocol.ExitNotFound, "no secret %s on %s", args[1], repo.Path())
510 }
511 return c.fail(protocol.ExitFailure, "%v", err)
512 }
513 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
514 fmt.Fprintf(w, "removed %s\n", args[1])
515 })
516}
517
518func runSecretList(c *Ctx, args []string) int {
519 if len(args) != 1 {
520 return c.usage()
521 }
522 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
523 if code >= 0 {
524 return code
525 }
526 names, err := c.Store.ListBuildSecretNames(repo.ID)
527 if err != nil {
528 return c.fail(protocol.ExitFailure, "%v", err)
529 }
530 return c.emit(names, func(w io.Writer) {
531 tb := c.table(w, "NAME")
532 for _, n := range names {
533 tb.row(cRef(n))
534 }
535 tb.flush()
536 })
537}
538
539// runnerSession resolves the key behind a runner-protocol session:
540// Source is the key's fingerprint. A build is claimed by a key, so a
541// session without one is told so plainly rather than half-running. An
542// admin key is accepted so an operator can rotate at their own pace; a
543// runner host should hold a key added with --scope runner.
544func runnerSession(c *Ctx) (store.SSHKey, int) {
545 if c.Scope != "runner" && !c.User.IsAdmin {
546 return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner")
547 }
548 key, err := c.Store.SSHKeyByFingerprint(c.Source)
549 if err != nil {
550 return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need an SSH key session")
551 }
552 return key, -1
553}
554
555// runnerAdmin reports whether a session claims builds instance-wide. The
556// bypass is the key, not the account: a scope-runner key is confined to
557// its attachments whoever owns it, including an instance admin.
558func runnerAdmin(c *Ctx) bool {
559 return c.User.IsAdmin && c.Scope != "runner"
560}
561
562// runnerMayBuild reports whether a runner session may act on a
563// repository's builds: an admin key may on any, a runner key on the
564// repositories it is attached to (#184).
565func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) {
566 if runnerAdmin(c) {
567 return true, nil
568 }
569 return c.Store.RunnerAttached(key.ID, repoID)
570}
571
572// maxOrphanSkip bounds how many claimed builds runRunnerNext will find
573// unreachable and cancel in one call before giving up. Only fast-forward
574// merges are allowed here, so any branch whose target advances gets
575// rebased and force-pushed, and a stack of branches can do that repeatedly
576// in one sitting — the issue this guards saw five in an afternoon. The cap
577// is well above that, so a real backlog is never cut short, while a
578// repository whose queue is orphaned end to end still returns rather than
579// walking it forever.
580const maxOrphanSkip = 50
581
582// publicSSH is the instance's ssh destination as anyone outside reaches
583// it. A runner on the daemon's own host polls over loopback and takes
584// the port from it for its builds' GITBAY_SSH, which names pasta's
585// address for the host (#260). The port is added only when it is not
586// 22: hutch and orgo build ssh://$GITBAY_SSH/... URLs, valid in both
587// forms. Empty when site_url is not set.
588func publicSSH(c *Ctx) string {
589 host := c.Cfg.SiteHost()
590 if host == "" {
591 return ""
592 }
593 if p := c.Cfg.SSH.Port; p != 0 && p != 22 {
594 return "git@" + net.JoinHostPort(host, strconv.Itoa(p))
595 }
596 return "git@" + host
597}
598
599func runRunnerNext(c *Ctx, args []string) int {
600 key, code := runnerSession(c)
601 if code >= 0 {
602 return code
603 }
604 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1,
605 Usage: "runner next [--untrusted] [<owner/name>...]"})
606 if err != nil {
607 return c.fail(protocol.ExitUsage, "%v", err)
608 }
609 // The candidate set. An admin key claims from any repository, narrowed
610 // by the names given. A runner key claims from the repositories it is
611 // attached to; a name outside them is refused, not ignored, so a
612 // misconfigured runner says so instead of idling.
613 var repoIDs []int64
614 for _, arg := range f.Pos {
615 repo, code := resolveRepo(c, arg, policy.CanRead)
616 if code >= 0 {
617 return code
618 }
619 ok, err := runnerMayBuild(c, key, repo.ID)
620 if err != nil {
621 return c.fail(protocol.ExitFailure, "%v", err)
622 }
623 if !ok {
624 return c.fail(protocol.ExitDenied, "this key is not attached to %s; a repository admin attaches it with repo runner add", repo.Path())
625 }
626 repoIDs = append(repoIDs, repo.ID)
627 }
628 if !runnerAdmin(c) && len(repoIDs) == 0 {
629 repoIDs, err = c.Store.RunnerRepoIDs(key.ID)
630 if err != nil {
631 return c.fail(protocol.ExitFailure, "%v", err)
632 }
633 if len(repoIDs) == 0 {
634 // Nothing attached: nothing to claim. Still a heartbeat, so
635 // admin runners shows the key polling.
636 c.Store.TouchRunner(key.ID, c.User.ID, "", 0)
637 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
638 }
639 }
640 untrusted := f.Has("--untrusted")
641 var b store.Build
642 var repo store.Repo
643 var ok bool
644 for attempt := 0; attempt < maxOrphanSkip; attempt++ {
645 b, ok, err = c.Store.ClaimBuild(repoIDs, untrusted)
646 if err != nil {
647 return c.fail(protocol.ExitFailure, "%v", err)
648 }
649 if !ok {
650 break
651 }
652 repo, err = c.Store.RepoByID(b.RepoID)
653 if err != nil {
654 return c.fail(protocol.ExitFailure, "%v", err)
655 }
656 // Only fast-forward merges are allowed here, so a target that
657 // advances gets rebased and force-pushed, orphaning whatever was
658 // queued for the old head: the runner would clone the repo and
659 // fail at checkout with a git internal error that reads exactly
660 // like a real failure. Catch it here instead. A check that itself
661 // fails is not evidence of anything — the build runs for real and
662 // is left to fail on its own terms, never cancelled on a guess.
663 reachable, err := gitutil.Reachable(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), b.SHA)
664 if err != nil || reachable {
665 break
666 }
667 if code := cancelOrphanedBuild(c, repo, b); code >= 0 {
668 return code
669 }
670 // Cancelled, not claimed: if the cap is hit right here, the runner
671 // heartbeat below must not record this build as the one handed out.
672 b, ok = store.Build{}, false
673 }
674 // The poll itself is the runner's heartbeat: admin runners reads it.
675 c.Store.TouchRunner(key.ID, c.User.ID, strings.Join(f.Pos, ","), b.ID)
676 if !ok {
677 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
678 }
679 var steps []string
680 json.Unmarshal([]byte(b.Steps), &steps)
681 // Secrets ride the claim: this channel is admin-only and the values
682 // land in the build's environment, nowhere else.
683 var secrets map[string]string
684 if b.Trusted {
685 secrets, err = c.Store.BuildSecrets(b.RepoID)
686 if err != nil {
687 return c.fail(protocol.ExitFailure, "%v", err)
688 }
689 }
690 d := struct {
691 ID int64 `json:"id"`
692 Repo string `json:"repo"`
693 Number int64 `json:"number"`
694 Job string `json:"job"`
695 SHA string `json:"sha"`
696 Ref string `json:"ref"`
697 Steps []string `json:"steps"`
698 Image string `json:"image,omitempty"`
699 // Trusted is always sent: a runner decides a build's home and
700 // secrets from it, and reads a missing field as untrusted (#255).
701 Trusted bool `json:"trusted"`
702 // SSH is the instance's public destination; a runner polling
703 // over loopback takes its port for the build's GITBAY_SSH (#260).
704 SSH string `json:"ssh,omitempty"`
705 Secrets map[string]string `json:"secrets,omitempty"`
706 }{ID: b.ID, Repo: repo.Path(), Number: b.Number, Job: b.Job, SHA: b.SHA, Ref: b.Ref,
707 Steps: steps, Image: b.Image, Trusted: b.Trusted, SSH: publicSSH(c), Secrets: secrets}
708 return c.emit(d, func(w io.Writer) {
709 fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
710 })
711}
712
713func runRunnerLog(c *Ctx, args []string) int {
714 key, code := runnerSession(c)
715 if code >= 0 {
716 return code
717 }
718 if len(args) != 1 {
719 return c.usage()
720 }
721 id, err := strconv.ParseInt(args[0], 10, 64)
722 if err != nil {
723 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
724 }
725 if b, err := c.Store.BuildByID(id); err != nil {
726 return c.fail(protocol.ExitNotFound, "no build %d", id)
727 } else if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
728 return c.fail(protocol.ExitFailure, "%v", err)
729 } else if !ok {
730 return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository; a repository admin attaches it with repo runner add")
731 }
732 // Stream stdin into the log in chunks so long builds appear live. An
733 // append that fails drops its chunk and the loop keeps draining: ending
734 // the session here breaks the runner's pipe, and a broken pipe is how a
735 // transient SQLITE_BUSY used to fail the build the log belonged to.
736 //
737 // The session is also how a running build is cancelled: while it is
738 // open the build's row is watched, and when the row stops saying
739 // running the session ends with ExitNotFound, which the runner reads as
740 // "stop this build". Any other end of the session is a lost stream.
741 type chunk struct {
742 data []byte
743 err error
744 }
745 chunks := make(chan chunk, 4)
746 go func() {
747 buf := make([]byte, 64<<10)
748 for {
749 n, rerr := c.Stdin.Read(buf)
750 if n > 0 {
751 chunks <- chunk{data: append([]byte(nil), buf[:n]...)}
752 }
753 if rerr != nil {
754 chunks <- chunk{err: rerr}
755 return
756 }
757 }
758 }()
759 watch := time.NewTicker(2 * time.Second)
760 defer watch.Stop()
761 dropped := 0
762 for {
763 select {
764 case ch := <-chunks:
765 if len(ch.data) > 0 {
766 if err := c.Store.AppendBuildLog(id, ch.data); err != nil {
767 dropped++
768 slog.Warn("appending build log", "build", id, "err", err)
769 }
770 }
771 if ch.err != nil {
772 if dropped > 0 {
773 slog.Warn("build log incomplete", "build", id, "dropped_chunks", dropped)
774 }
775 // The stream ending is the last thing the server hears
776 // from a runner that is about to die; note the time so
777 // the scheduler can fail the build if no outcome follows.
778 if err := c.Store.MarkBuildLogClosed(id); err != nil {
779 slog.Warn("marking build log closed", "build", id, "err", err)
780 }
781 return c.emit(map[string]string{"log": "ok"}, func(w io.Writer) {})
782 }
783 case <-watch.C:
784 if b, err := c.Store.BuildByID(id); err == nil && b.Status != "running" {
785 return c.fail(protocol.ExitNotFound, "build %d is %s; stop", id, b.Status)
786 }
787 }
788 }
789}
790
791func runRunnerDone(c *Ctx, args []string) int {
792 key, code := runnerSession(c)
793 if code >= 0 {
794 return code
795 }
796 f, err := c.parseArgs(args, flagSpec{Values: []string{"--step", "--reason"}, MaxPos: 2, Usage: c.Cmd.Usage})
797 if err != nil {
798 return c.fail(protocol.ExitUsage, "%v", err)
799 }
800 if len(f.Pos) != 2 || (f.Pos[1] != "success" && f.Pos[1] != "failure") {
801 return c.usage()
802 }
803 outcome := f.Pos[1]
804 id, err := strconv.ParseInt(f.Pos[0], 10, 64)
805 if err != nil {
806 return c.fail(protocol.ExitUsage, "bad build id %q", f.Pos[0])
807 }
808 b, err := c.Store.BuildByID(id)
809 if err != nil {
810 return c.fail(protocol.ExitNotFound, "no build %d", id)
811 }
812 if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
813 return c.fail(protocol.ExitFailure, "%v", err)
814 } else if !ok {
815 return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository; a repository admin attaches it with repo runner add")
816 }
817 // Cancelled underneath the runner: its report is late, not wrong.
818 // The row, the status and the log were settled by the cancel.
819 if b.Status == "cancelled" {
820 c.Store.RunnerDone(key.ID)
821 return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) {
822 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
823 })
824 }
825 if outcome == "failure" {
826 // A step the job does not have is recorded as none rather than
827 // refused: refusing would lose the outcome over a detail (#266).
828 var steps []string
829 json.Unmarshal([]byte(b.Steps), &steps)
830 step, _ := strconv.Atoi(f.Value("--step"))
831 if step < 0 || step > len(steps) {
832 step = 0
833 }
834 if err := c.Store.SetBuildFailure(id, step, failureReason(f.Value("--reason"))); err != nil && !errors.Is(err, store.ErrNotFound) {
835 return c.fail(protocol.ExitFailure, "recording build %d's failure: %v", id, err)
836 }
837 }
838 if err := c.Store.FinishBuild(id, outcome); err != nil {
839 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
840 }
841 c.Store.RunnerDone(key.ID)
842 repo, err := c.Store.RepoByID(b.RepoID)
843 if err != nil {
844 return c.fail(protocol.ExitFailure, "%v", err)
845 }
846 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
847 desc := "build " + outcome
848 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, outcome, desc, url, c.User.ID); err != nil {
849 return c.fail(protocol.ExitFailure, "%v", err)
850 }
851 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome,
852 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
853 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, b.SHA)
854 // A red build mails the repo's notify targets with the log tail — a
855 // failed scheduled job must not wait to be noticed.
856 if outcome == "failure" {
857 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
858 tail := ""
859 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
860 if len(log) > 2000 {
861 log = log[len(log)-2000:]
862 }
863 tail = string(log)
864 }
865 notify(c, targets, notice{repo: repo, kind: "build",
866 subject: fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
867 action: fmt.Sprintf("build %d failed: %s on %s", b.Number, b.Job, b.Ref),
868 body: fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url),
869 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)})
870 }
871 }
872 return c.emit(map[string]any{"build": b.Number, "status": outcome}, func(w io.Writer) {
873 fmt.Fprintf(w, "build %d %s\n", b.Number, outcome)
874 })
875}
876
877// failureReason keeps a runner's reason to one line of at most 200
878// bytes: it is shown on the build page and by build show.
879func failureReason(s string) string {
880 s = strings.Join(strings.Fields(s), " ")
881 if len(s) > 200 {
882 s = s[:200]
883 }
884 return strings.ToValidUTF8(s, "")
885}
886
887// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
888// build per push job, with a pending commit status the runner resolves.
889// A broken config surfaces as a failed "ci/config" status, not silence.
890//
891// Both paths that move a branch call this: post-receive for a push, and
892// the merge path for a merge, which updates the ref directly and so never
893// reaches a hook. old is the branch's sha before this update, the diff
894// base a job's path filters run against; a new branch has no prior
895// commit and sends old as empty or all zeros. queueJobs falls back to
896// the merge base with the default branch in that case, so a filter
897// still applies to a branch's first push — the shape most changes have,
898// since branch-then-MR is the normal workflow here.
899func QueueBranchBuilds(
900 st *store.Store, root, siteURL string,
901 repo store.Repo, userID int64, branch, old, sha string, now time.Time,
902) {
903 queueJobs(st, root, siteURL, repo, userID, branch, old, sha, now, true, branch == repo.DefaultBranch, true)
904}
905
906// QueueMRBuilds queues the push jobs for a merge request head fetched
907// from another repository, which the target holds at
908// refs/merge-requests/<n>/head, so a fork's merge request has ci/<job>
909// statuses for require-checks to gate on (#98). The head is untrusted:
910// its build runs without the target's secrets. A same-repository head is
911// the branch push's job and is not queued here; a failed one is rebuilt
912// when it lands, not when it is proposed.
913func QueueMRBuilds(
914 st *store.Store, root, siteURL string,
915 repo store.Repo, userID, n int64, sha string,
916) {
917 // No old sha, and unlike QueueBranchBuilds, no merge-base fallback
918 // either: this deliberately keeps failing open and running every
919 // job. require_checks refuses a merge when an MR head has no
920 // statuses at all (mr.go), so filtering a head down to zero jobs
921 // would make it unmergeable rather than just unfiltered (#172).
922 queueJobs(st, root, siteURL, repo, userID, mrHeadRef(n), "", sha, time.Now(), false, false, false)
923}
924
925// skipReason names why a job's path filters excluded this push, mirroring
926// the order ci.Selected checks them in: an unmatched paths list rules a
927// job out before paths-ignore is even considered.
928func skipReason(j ci.Job, changed []string) string {
929 if len(j.Paths) > 0 {
930 hit := false
931 for _, f := range changed {
932 for _, p := range j.Paths {
933 if ci.Match(p, f) {
934 hit = true
935 }
936 }
937 }
938 if !hit {
939 return "no changed file matches paths"
940 }
941 }
942 return "every changed file matched paths-ignore"
943}
944
945func queueJobs(
946 st *store.Store, root, siteURL string,
947 repo store.Repo, userID int64, ref, old, sha string, now time.Time,
948 trusted, syncSchedules, deriveMergeBase bool,
949) {
950 dir := RepoDir(root, repo.OwnerName, repo.Name)
951 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
952 if err != nil {
953 return // no CI config at this commit
954 }
955 jobs, err := ci.Parse(raw)
956 if err != nil {
957 st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
958 return
959 }
960 // A build is a fact about a commit, not a ref: a job has no branch
961 // filter, so a commit that already passed a job on another branch has
962 // nothing left to prove when a fast-forward lands it here, and one
963 // still queued or running there will say soon enough. A failed,
964 // abandoned or cancelled build does not count; that commit runs again.
965 built, err := st.BuildsForCommit(repo.ID, sha)
966 if err != nil {
967 built = nil
968 }
969 // A job's result is a property of the tree, not the commit: a rebase
970 // onto a base that touched nothing the branch did gives every commit
971 // a new sha and the same tree, and re-running the suite over it
972 // proves nothing it did not already prove (#177). A success recorded
973 // against the tree stands for the new commit.
974 tree, _ := gitutil.ResolveTree(dir, sha)
975 // The changed-file list a job's path filters run against, computed
976 // once and only if some job actually declares one. When the diff
977 // base does not exist or the diff itself fails, filtered stays
978 // false and every job runs: a filter that cannot be evaluated must
979 // not silently skip CI.
980 //
981 // A branch's first push has no old sha, but a diff base still
982 // exists: the merge base with the default branch. Without deriving
983 // one, every job runs on every new branch, and since branch-then-MR
984 // is the normal workflow, that is the push path filters matter most
985 // for. The merge base of the default branch's tip with itself is
986 // the tip, carrying no diff — that covers the default branch's own
987 // first push on a fresh repository, and must fail open rather than
988 // read as "nothing changed".
989 filtered := false
990 var changed []string
991 for _, j := range jobs {
992 if len(j.Paths) == 0 && len(j.PathsIgnore) == 0 {
993 continue
994 }
995 diffOld := old
996 // A force-push rewrote the branch, so the old tip is not an
997 // ancestor of the new one and old..new is not "what this push
998 // changed" — it is the difference between two histories. After a
999 // rebase that is whatever the new base added, typically nothing
1000 // the branch itself touched, so every path filter concludes its
1001 // job is unnecessary and the branch reads as green without its
1002 // suite having run (#176). The merge base is the honest base:
1003 // the filter is deciding about the branch's relationship to its
1004 // target, which is what the merge base expresses.
1005 if ci.HasDiffBase(diffOld) && deriveMergeBase {
1006 if ok, err := gitutil.IsAncestor(dir, diffOld, sha); err != nil || !ok {
1007 diffOld = ""
1008 }
1009 }
1010 if !ci.HasDiffBase(diffOld) && deriveMergeBase {
1011 if base, err := gitutil.MergeBase(dir, "refs/heads/"+repo.DefaultBranch, sha); err == nil && base != sha {
1012 diffOld = base
1013 }
1014 }
1015 if ci.HasDiffBase(diffOld) {
1016 if files, err := gitutil.DiffFiles(dir, diffOld, sha); err == nil {
1017 changed, filtered = files, true
1018 }
1019 }
1020 break
1021 }
1022 var schedules []store.Schedule
1023 for _, j := range jobs {
1024 // Tag jobs run on matching tag pushes only.
1025 if j.Tags != "" {
1026 continue
1027 }
1028 // A build of this commit that passed, or is queued or running,
1029 // stands for it — unless this queue is trusted and that build was
1030 // not: a fork's head that lands on a branch is built again as the
1031 // repository's own (#258).
1032 if b, ok := built[j.Name]; ok && (b.Trusted || !trusted) &&
1033 (b.Status == "success" || b.Status == "pending" || b.Status == "running") {
1034 continue
1035 }
1036 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name, j.Image); ok && prev.SHA != sha {
1037 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), prev.Number)
1038 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "success",
1039 fmt.Sprintf("passed in build %d as %.10s, same tree", prev.Number, prev.SHA), url, userID)
1040 continue
1041 }
1042 // Scheduled jobs run on their cron, not on push; a default-branch
1043 // push (re)registers them.
1044 if j.Schedule != "" {
1045 if syncSchedules {
1046 schedules = append(schedules, store.Schedule{
1047 RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
1048 NextRun: ci.NextRun(j.Schedule, now),
1049 })
1050 }
1051 continue
1052 }
1053 // A filter that excludes this push is not silence: it satisfies
1054 // require_checks with a skipped status instead of leaving the
1055 // commit with none at all, which the gate refuses outright (#172).
1056 if filtered && !ci.Selected(j, changed) {
1057 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "skipped", skipReason(j, changed), "", userID)
1058 continue
1059 }
1060 steps, _ := json.Marshal(j.Steps)
1061 n, err := st.CreateBuild(repo.ID, j.Name, sha, ref, string(steps), j.Image, tree, trusted)
1062 if err != nil {
1063 slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
1064 continue
1065 }
1066 url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), n)
1067 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
1068 }
1069 if syncSchedules {
1070 if err := st.SyncSchedules(repo.ID, schedules); err != nil {
1071 slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
1072 }
1073 }
1074}
1075
1076// resolveCancelledCommitStatus sets the commit status for a build that was
1077// just cancelled: if the commit already passed this job on another ref,
1078// that result stands again; otherwise the context reports the
1079// cancellation as an error, so the queued status left behind is never
1080// pending forever.
1081func resolveCancelledCommitStatus(c *Ctx, repo store.Repo, b store.Build) {
1082 if prev, ok, err := c.Store.SuccessBuildFor(repo.ID, b.SHA, b.Job); err == nil && ok {
1083 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
1084 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
1085 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
1086 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, b.SHA)
1087 return
1088 }
1089 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
1090 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "error", "cancelled", url, c.User.ID)
1091}
1092
1093// cancelOrphanedBuild withdraws a build runRunnerNext claimed and then
1094// found unreachable. It leaves the same shape behind as a build cancel a
1095// person runs by hand: CancelBuild's status, a log line saying why, and
1096// the commit status resolved rather than left pending. Returns -1 to mean
1097// "handled, keep going"; anything else is the exit code to return.
1098func cancelOrphanedBuild(c *Ctx, repo store.Repo, b store.Build) int {
1099 if err := c.Store.CancelBuild(b.ID); err != nil {
1100 return c.fail(protocol.ExitFailure, "%v", err)
1101 }
1102 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf(
1103 "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA)))
1104 resolveCancelledCommitStatus(c, repo, b)
1105 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
1106 return -1
1107}
1108
1109func runBuildCancel(c *Ctx, args []string) int {
1110 repo, b, code := buildRef(c, args)
1111 if code >= 0 {
1112 return code
1113 }
1114 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1115 if err != nil {
1116 return c.fail(protocol.ExitFailure, "%v", err)
1117 }
1118 if !policy.CanWrite(c.User, repo, grant) {
1119 return c.fail(protocol.ExitDenied, "cancelling a build needs write access to %s; ask its owner", repo.Path())
1120 }
1121 if b.Status != "pending" && b.Status != "running" {
1122 return c.fail(protocol.ExitUsage, "build %d is %s; only a queued or running build can be cancelled", b.Number, b.Status)
1123 }
1124 if err := c.Store.CancelBuild(b.ID); err != nil {
1125 return c.fail(protocol.ExitFailure, "%v", err)
1126 }
1127 if b.Status == "running" {
1128 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("\ncancelled by %s while running; the runner stops at its next check\n", c.User.Username)))
1129 } else {
1130 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("cancelled by %s before a runner claimed it\n", c.User.Username)))
1131 }
1132 // The queued status replaced whatever the commit had for this job.
1133 resolveCancelledCommitStatus(c, repo, b)
1134 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
1135 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) {
1136 if b.Status == "running" {
1137 fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job)
1138 return
1139 }
1140 fmt.Fprintf(w, "cancelled %s build %d (%s)\n", repo.Path(), b.Number, b.Job)
1141 })
1142}