internal/control/build.go

627 lines · 22179 bytes

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