internal/control/build.go

1142 lines · 42879 bytes

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