internal/control/build.go

597 lines · 20751 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. Admin-only —
 57	// a runner executes arbitrary repo code, so handing out jobs is the
 58	// instance operator's call.
 59	register(Command{Path: []string{"runner", "next"},
 60		Summary: "claim the oldest pending build (runner protocol)",
 61		Usage:   "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
 62	register(Command{Path: []string{"runner", "log"},
 63		Summary: "append a build's log from stdin",
 64		Usage:   "runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog})
 65	register(Command{Path: []string{"runner", "done"},
 66		Summary: "finish a build",
 67		Usage:   "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone})
 68}
 69
 70type buildOut struct {
 71	Number     int64  `json:"number"`
 72	Job        string `json:"job"`
 73	Status     string `json:"status"`
 74	SHA        string `json:"sha"`
 75	Ref        string `json:"ref"`
 76	CreatedAt  string `json:"created_at"`
 77	FinishedAt string `json:"finished_at,omitempty"`
 78}
 79
 80func buildToOut(b store.Build) buildOut {
 81	return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
 82}
 83
 84func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
 85	if len(args) != 2 {
 86		return store.Repo{}, store.Build{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
 87	}
 88	repo, code := resolveRepo(c, args[0], policy.CanRead)
 89	if code >= 0 {
 90		return repo, store.Build{}, code
 91	}
 92	n, err := strconv.ParseInt(args[1], 10, 64)
 93	if err != nil {
 94		return repo, store.Build{}, c.fail(protocol.ExitUsage, "bad build number %q", args[1])
 95	}
 96	b, err := c.Store.BuildByNumber(repo.ID, n)
 97	if err != nil {
 98		return repo, b, c.fail(protocol.ExitNotFound, "no build %d on %s", n, repo.Path())
 99	}
100	return repo, b, -1
101}
102
103func runBuildList(c *Ctx, args []string) int {
104	if len(args) != 1 {
105		return c.fail(protocol.ExitUsage, "usage: build list <owner/name>")
106	}
107	repo, code := resolveRepo(c, args[0], policy.CanRead)
108	if code >= 0 {
109		return code
110	}
111	builds, err := c.Store.ListBuilds(repo.ID, 50)
112	if err != nil {
113		return c.fail(protocol.ExitFailure, "%v", err)
114	}
115	var ds []buildOut
116	for _, b := range builds {
117		ds = append(ds, buildToOut(b))
118	}
119	return c.emit(ds, func(w io.Writer) {
120		for _, d := range ds {
121			fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref)
122		}
123	})
124}
125
126func runBuildShow(c *Ctx, args []string) int {
127	_, b, code := buildRef(c, args)
128	if code >= 0 {
129		return code
130	}
131	d := buildToOut(b)
132	return c.emit(d, func(w io.Writer) {
133		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)
134		if d.FinishedAt != "" {
135			fmt.Fprintf(w, ", finished %s", d.FinishedAt)
136		}
137		fmt.Fprintln(w)
138	})
139}
140
141func runBuildLog(c *Ctx, args []string) int {
142	_, b, code := buildRef(c, args)
143	if code >= 0 {
144		return code
145	}
146	log, err := c.Store.BuildLog(b.ID)
147	if err != nil {
148		return c.fail(protocol.ExitFailure, "%v", err)
149	}
150	c.Stdout.Write(log)
151	return protocol.ExitOK
152}
153
154type jobOut struct {
155	Name     string `json:"name"`
156	Schedule string `json:"schedule,omitempty"`
157	Tags     string `json:"tags,omitempty"`
158}
159
160// repoJobs reads the CI config on the default branch — the same file the
161// scheduler reads — and returns its jobs with the sha they came from.
162func repoJobs(c *Ctx, repo store.Repo) ([]ci.Job, string, int) {
163	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
164	sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
165	if err != nil {
166		return nil, "", c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err)
167	}
168	raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
169	if err != nil {
170		return nil, "", c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch)
171	}
172	jobs, err := ci.Parse(raw)
173	if err != nil {
174		return nil, "", c.fail(protocol.ExitUsage, "%v", err)
175	}
176	return jobs, sha, -1
177}
178
179// runBuildJobs answers "what can I trigger?". Without it only a surface
180// that can read the repository's git could offer the choice.
181func runBuildJobs(c *Ctx, args []string) int {
182	if len(args) != 1 {
183		return c.fail(protocol.ExitUsage, "usage: build jobs <owner/name>")
184	}
185	repo, code := resolveRepo(c, args[0], policy.CanRead)
186	if code >= 0 {
187		return code
188	}
189	jobs, _, code := repoJobs(c, repo)
190	if code >= 0 {
191		return code
192	}
193	out := make([]jobOut, 0, len(jobs))
194	for _, j := range jobs {
195		out = append(out, jobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
196	}
197	return c.emit(out, func(w io.Writer) {
198		for _, j := range out {
199			switch {
200			case j.Schedule != "":
201				fmt.Fprintf(w, "%s\tschedule %s\n", j.Name, j.Schedule)
202			case j.Tags != "":
203				fmt.Fprintf(w, "%s\ttags %s\n", j.Name, j.Tags)
204			default:
205				fmt.Fprintf(w, "%s\ton push\n", j.Name)
206			}
207		}
208	})
209}
210
211func runBuildTrigger(c *Ctx, args []string) int {
212	if len(args) != 2 {
213		return c.fail(protocol.ExitUsage, "usage: build trigger <owner/name> <job>")
214	}
215	repo, code := resolveRepo(c, args[0], policy.CanWrite)
216	if code >= 0 {
217		return code
218	}
219	jobs, sha, code := repoJobs(c, repo)
220	if code >= 0 {
221		return code
222	}
223	for _, j := range jobs {
224		if j.Name != args[1] {
225			continue
226		}
227		steps, _ := json.Marshal(j.Steps)
228		n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps))
229		if err != nil {
230			return c.fail(protocol.ExitFailure, "%v", err)
231		}
232		url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), n)
233		c.Store.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "triggered", url, c.User.ID)
234		return c.emit(map[string]any{"build": n, "job": j.Name, "sha": sha}, func(w io.Writer) {
235			fmt.Fprintf(w, "queued build %d (%s @ %.10s)\n", n, j.Name, sha)
236		})
237	}
238	return c.fail(protocol.ExitNotFound, "no job %q in %s", args[1], ci.ConfigPath)
239}
240
241// secretName is env-var shaped: the value lands in the build environment.
242var secretName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`)
243
244func runSecretSet(c *Ctx, args []string) int {
245	if len(args) != 2 {
246		return c.fail(protocol.ExitUsage, "usage: repo secret set <owner/name> <NAME> (value on stdin)")
247	}
248	if !secretName.MatchString(args[1]) {
249		return c.fail(protocol.ExitUsage, "secret names are env-var shaped: uppercase letters, digits, _")
250	}
251	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
252	if code >= 0 {
253		return code
254	}
255	raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
256	if err != nil {
257		return c.fail(protocol.ExitFailure, "reading secret: %v", err)
258	}
259	value := strings.TrimRight(string(raw), "\n")
260	if value == "" {
261		return c.fail(protocol.ExitUsage, "no value on stdin (pipe it: printf %%s TOKEN | ...)")
262	}
263	if err := c.Store.SetBuildSecret(repo.ID, args[1], value); err != nil {
264		return c.fail(protocol.ExitFailure, "%v", err)
265	}
266	return c.emit(map[string]string{"secret": args[1]}, func(w io.Writer) {
267		fmt.Fprintf(w, "secret %s set on %s\n", args[1], repo.Path())
268	})
269}
270
271func runSecretRemove(c *Ctx, args []string) int {
272	if len(args) != 2 {
273		return c.fail(protocol.ExitUsage, "usage: repo secret remove <owner/name> <NAME>")
274	}
275	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
276	if code >= 0 {
277		return code
278	}
279	if err := c.Store.RemoveBuildSecret(repo.ID, args[1]); err != nil {
280		if errors.Is(err, store.ErrNotFound) {
281			return c.fail(protocol.ExitNotFound, "no secret %s on %s", args[1], repo.Path())
282		}
283		return c.fail(protocol.ExitFailure, "%v", err)
284	}
285	return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
286		fmt.Fprintf(w, "removed %s\n", args[1])
287	})
288}
289
290func runSecretList(c *Ctx, args []string) int {
291	if len(args) != 1 {
292		return c.fail(protocol.ExitUsage, "usage: repo secret list <owner/name>")
293	}
294	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
295	if code >= 0 {
296		return code
297	}
298	names, err := c.Store.ListBuildSecretNames(repo.ID)
299	if err != nil {
300		return c.fail(protocol.ExitFailure, "%v", err)
301	}
302	return c.emit(names, func(w io.Writer) {
303		for _, n := range names {
304			fmt.Fprintln(w, n)
305		}
306	})
307}
308
309func requireRunner(c *Ctx) int {
310	if !c.User.IsAdmin {
311		return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts")
312	}
313	return -1
314}
315
316func runRunnerNext(c *Ctx, args []string) int {
317	if code := requireRunner(c); code >= 0 {
318		return code
319	}
320	// A runner may limit itself to named repositories. The operator chooses
321	// what a given runner executes by how they start it; this is scoping the
322	// runner asks for, not an ACL the server holds over it.
323	var repoIDs []int64
324	for _, arg := range args {
325		repo, code := resolveRepo(c, arg, policy.CanRead)
326		if code >= 0 {
327			return code
328		}
329		repoIDs = append(repoIDs, repo.ID)
330	}
331	b, ok, err := c.Store.ClaimBuild(repoIDs)
332	if err != nil {
333		return c.fail(protocol.ExitFailure, "%v", err)
334	}
335	// The poll itself is the runner's heartbeat: admin runners reads it.
336	c.Store.TouchRunner(c.User.ID, strings.Join(args, ","), b.ID)
337	if !ok {
338		return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
339	}
340	repo, err := c.Store.RepoByID(b.RepoID)
341	if err != nil {
342		return c.fail(protocol.ExitFailure, "%v", err)
343	}
344	var steps []string
345	json.Unmarshal([]byte(b.Steps), &steps)
346	// Secrets ride the claim: this channel is admin-only and the values
347	// land in the build's environment, nowhere else.
348	secrets, err := c.Store.BuildSecrets(b.RepoID)
349	if err != nil {
350		return c.fail(protocol.ExitFailure, "%v", err)
351	}
352	d := struct {
353		ID      int64             `json:"id"`
354		Repo    string            `json:"repo"`
355		Number  int64             `json:"number"`
356		Job     string            `json:"job"`
357		SHA     string            `json:"sha"`
358		Ref     string            `json:"ref"`
359		Steps   []string          `json:"steps"`
360		Secrets map[string]string `json:"secrets,omitempty"`
361	}{b.ID, repo.Path(), b.Number, b.Job, b.SHA, b.Ref, steps, secrets}
362	return c.emit(d, func(w io.Writer) {
363		fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
364	})
365}
366
367func runRunnerLog(c *Ctx, args []string) int {
368	if code := requireRunner(c); code >= 0 {
369		return code
370	}
371	if len(args) != 1 {
372		return c.fail(protocol.ExitUsage, "usage: runner log <build-id> (chunk on stdin)")
373	}
374	id, err := strconv.ParseInt(args[0], 10, 64)
375	if err != nil {
376		return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
377	}
378	// Stream stdin into the log in chunks so long builds appear live. An
379	// append that fails drops its chunk and the loop keeps draining: ending
380	// the session here breaks the runner's pipe, and a broken pipe is how a
381	// transient SQLITE_BUSY used to fail the build the log belonged to.
382	//
383	// The session is also how a running build is cancelled: while it is
384	// open the build's row is watched, and when the row stops saying
385	// running the session ends with ExitNotFound, which the runner reads as
386	// "stop this build". Any other end of the session is a lost stream.
387	type chunk struct {
388		data []byte
389		err  error
390	}
391	chunks := make(chan chunk, 4)
392	go func() {
393		buf := make([]byte, 64<<10)
394		for {
395			n, rerr := c.Stdin.Read(buf)
396			if n > 0 {
397				chunks <- chunk{data: append([]byte(nil), buf[:n]...)}
398			}
399			if rerr != nil {
400				chunks <- chunk{err: rerr}
401				return
402			}
403		}
404	}()
405	watch := time.NewTicker(2 * time.Second)
406	defer watch.Stop()
407	dropped := 0
408	for {
409		select {
410		case ch := <-chunks:
411			if len(ch.data) > 0 {
412				if err := c.Store.AppendBuildLog(id, ch.data); err != nil {
413					dropped++
414					slog.Warn("appending build log", "build", id, "err", err)
415				}
416			}
417			if ch.err != nil {
418				if dropped > 0 {
419					slog.Warn("build log incomplete", "build", id, "dropped_chunks", dropped)
420				}
421				return c.emit(map[string]string{"log": "ok"}, func(w io.Writer) {})
422			}
423		case <-watch.C:
424			if b, err := c.Store.BuildByID(id); err == nil && b.Status != "running" {
425				return c.fail(protocol.ExitNotFound, "build %d is %s; stop", id, b.Status)
426			}
427		}
428	}
429}
430
431func runRunnerDone(c *Ctx, args []string) int {
432	if code := requireRunner(c); code >= 0 {
433		return code
434	}
435	if len(args) != 2 || (args[1] != "success" && args[1] != "failure") {
436		return c.fail(protocol.ExitUsage, "usage: runner done <build-id> success|failure")
437	}
438	id, err := strconv.ParseInt(args[0], 10, 64)
439	if err != nil {
440		return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
441	}
442	b, err := c.Store.BuildByID(id)
443	if err != nil {
444		return c.fail(protocol.ExitNotFound, "no build %d", id)
445	}
446	// Cancelled underneath the runner: its report is late, not wrong.
447	// The row, the status and the log were settled by the cancel.
448	if b.Status == "cancelled" {
449		c.Store.RunnerDone(c.User.ID)
450		return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) {
451			fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
452		})
453	}
454	if err := c.Store.FinishBuild(id, args[1]); err != nil {
455		return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
456	}
457	c.Store.RunnerDone(c.User.ID)
458	repo, err := c.Store.RepoByID(b.RepoID)
459	if err != nil {
460		return c.fail(protocol.ExitFailure, "%v", err)
461	}
462	url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
463	desc := "build " + args[1]
464	if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil {
465		return c.fail(protocol.ExitFailure, "%v", err)
466	}
467	c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
468		fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
469	// A red build mails the repo's notify targets with the log tail — a
470	// failed scheduled job must not wait to be noticed.
471	if args[1] == "failure" {
472		if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
473			tail := ""
474			if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
475				if len(log) > 2000 {
476					log = log[len(log)-2000:]
477				}
478				tail = string(log)
479			}
480			notifyUsers(c, targets,
481				fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
482				fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url))
483		}
484	}
485	return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
486		fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
487	})
488}
489
490// QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending
491// build per push job, with a pending commit status the runner resolves.
492// A broken config surfaces as a failed "ci/config" status, not silence.
493//
494// Both paths that move a branch call this: post-receive for a push, and
495// the merge path for a merge, which updates the ref directly and so never
496// reaches a hook.
497func QueueBranchBuilds(
498	st *store.Store, root, siteURL string,
499	repo store.Repo, userID int64, branch, sha string, now time.Time,
500) {
501	dir := RepoDir(root, repo.OwnerName, repo.Name)
502	raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
503	if err != nil {
504		return // no CI config at this commit
505	}
506	jobs, err := ci.Parse(raw)
507	if err != nil {
508		st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
509		return
510	}
511	// A build is a fact about a commit, not a ref: a job has no branch
512	// filter, so a commit that already passed a job on another branch has
513	// nothing left to prove when a fast-forward lands it here. A failed or
514	// abandoned build does not count; that commit runs again.
515	built, err := st.BuildsForCommit(repo.ID, sha)
516	if err != nil {
517		built = nil
518	}
519	var schedules []store.Schedule
520	for _, j := range jobs {
521		// Tag jobs run on matching tag pushes only.
522		if j.Tags != "" {
523			continue
524		}
525		if b, ok := built[j.Name]; ok && b.Status == "success" {
526			continue
527		}
528		// Scheduled jobs run on their cron, not on push; a default-branch
529		// push (re)registers them.
530		if j.Schedule != "" {
531			if branch == repo.DefaultBranch {
532				schedules = append(schedules, store.Schedule{
533					RepoID: repo.ID, Job: j.Name, Cron: j.Schedule,
534					NextRun: ci.NextRun(j.Schedule, now),
535				})
536			}
537			continue
538		}
539		steps, _ := json.Marshal(j.Steps)
540		n, err := st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
541		if err != nil {
542			slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
543			continue
544		}
545		url := fmt.Sprintf("%s/%s/builds/%d", siteURL, repo.Path(), n)
546		st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
547	}
548	if branch == repo.DefaultBranch {
549		if err := st.SyncSchedules(repo.ID, schedules); err != nil {
550			slog.Error("syncing schedules", "repo", repo.Path(), "err", err)
551		}
552	}
553}
554
555func runBuildCancel(c *Ctx, args []string) int {
556	repo, b, code := buildRef(c, args)
557	if code >= 0 {
558		return code
559	}
560	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
561	if err != nil {
562		return c.fail(protocol.ExitFailure, "%v", err)
563	}
564	if !policy.CanWrite(c.User, repo, grant) {
565		return c.fail(protocol.ExitDenied, "cancelling a build needs write access to %s", repo.Path())
566	}
567	if b.Status != "pending" && b.Status != "running" {
568		return c.fail(protocol.ExitUsage, "build %d is %s; only a queued or running build can be cancelled", b.Number, b.Status)
569	}
570	if err := c.Store.CancelBuild(b.ID); err != nil {
571		return c.fail(protocol.ExitFailure, "%v", err)
572	}
573	if b.Status == "running" {
574		c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("\ncancelled by %s while running; the runner stops at its next check\n", c.User.Username)))
575	} else {
576		c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("cancelled by %s before a runner claimed it\n", c.User.Username)))
577	}
578	// The queued status replaced whatever the commit had for this job. If
579	// the commit passed the job on another ref, that result stands again;
580	// otherwise the context says it was withdrawn.
581	if prev, ok, err := c.Store.SuccessBuildFor(repo.ID, b.SHA, b.Job); err == nil && ok {
582		url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
583		c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
584			fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
585	} else {
586		url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
587		c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "error", "cancelled", url, c.User.ID)
588	}
589	c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
590	return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) {
591		if b.Status == "running" {
592			fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job)
593			return
594		}
595		fmt.Fprintf(w, "cancelled %s build %d (%s)\n", repo.Path(), b.Number, b.Job)
596	})
597}