internal/control/build.go

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

1205 lines · 45259 bytes

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