internal/control/build.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/internal/control/build.go history · blame · raw

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