Commit dfd79e9c40

dfd79e9c4095a977522fb761183967cfb786ea06

parent: b1f7d169de

Verified · cmc ci/build: error ci/test: error ci/vuln: error

cmc <hello@cleberg.net> · 2026-09-02 03:45 UTC

build cancel: withdraw a queued build

Nothing could take a build out of the queue: seventeen duplicates sat
behind a runner with no way to drain them short of editing the
database on the host.

build cancel <owner/name> <n> marks a pending build cancelled, a state
runners never claim, and notes who withdrew it in the log. It needs
write access, like trigger. A running build is refused: it is the
runner's to finish, and a row saying otherwise would contradict its
report. The queued commit status is replaced: with the passed result
from another build of the same commit and job when one exists, which
is the duplicate case, and with an error reading "cancelled" otherwise.
Badges and chips paint cancelled neutral.

Ref #90

Layout: unified · split

cmd/gitbay/main.go +1
@@ -45,6 +45,7 @@ func newRoot() *cobra.Command {
4545 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
4646 pass("jobs", "list the jobs a trigger can name", passOpts{server: []string{"build", "jobs"}, needsRepo: true}),
4747 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
48 pass("cancel", "withdraw a queued build: <n>", passOpts{server: []string{"build", "cancel"}, needsRepo: true}),
4849 ),
4950 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
5051 passOpts{server: []string{"dashboard"}}),
e2e/build_cancel_test.go added +95
@@ -0,0 +1,95 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// build cancel withdraws a queued build; a running one is the runner's.
11// Cancelling a duplicate of a commit that already passed puts that
12// result back on the commit.
13func TestBuildCancel(t *testing.T) {
14 inst := startInstance(t)
15 inst.runner = buildRunner(t)
16 aliceKey := inst.newKey(t, "alice")
17 bobKey := inst.newKey(t, "bob")
18 runnerKey := inst.newKey(t, "ci")
19 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
20 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
21 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatal("repo create failed")
24 }
25 work := t.TempDir()
26 env := inst.gitEnv(aliceKey)
27 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
28 dir := filepath.Join(work, "w")
29 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
30 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo fine\n"), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35 sha := strings.Fields(strings.TrimSpace(mustGit(t, dir, env, "ls-remote", "origin", "refs/heads/main")))[0]
36
37 // Build 1 is queued. A reader cannot cancel it; the owner can.
38 if _, _, code := inst.ssh(t, bobKey, "", "build", "cancel", "alice/app", "1"); code != 4 {
39 t.Fatal("reader cancelled a build")
40 }
41 if out, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "1"); code != 0 || !strings.Contains(out, "cancelled alice/app build 1") {
42 t.Fatalf("cancel: exit %d %s%s", code, out, errOut)
43 }
44 if list := inst.buildList(t, aliceKey); !strings.Contains(list, "unit\tcancelled") {
45 t.Fatalf("build list after cancel:\n%s", list)
46 }
47 if out, _, _ := inst.ssh(t, aliceKey, "", "build", "log", "alice/app", "1"); !strings.Contains(out, "cancelled by alice") {
48 t.Fatalf("log after cancel:\n%s", out)
49 }
50 if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "error") || !strings.Contains(st, "cancelled") {
51 t.Fatalf("status after cancel:\n%s", st)
52 }
53 if _, _, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "1"); code != 2 {
54 t.Fatal("cancelled a cancelled build")
55 }
56 // Nothing for a runner to claim.
57 if out := inst.runnerOnce(t, runnerKey); strings.Contains(out, "unit") && !strings.Contains(out, "no pending") {
58 t.Fatalf("runner picked up a cancelled build:\n%s", out)
59 }
60 if status, _ := inst.get(t, "/alice/app/badge/build.svg"); status != 200 {
61 t.Fatalf("badge after cancel: %d", status)
62 }
63
64 // Build 2: triggered, run to success. Build 3: the same commit queued
65 // again; cancelling it restores the passed result on the commit.
66 if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
67 t.Fatal("trigger failed")
68 }
69 inst.runnerOnce(t, runnerKey)
70 if list := inst.buildList(t, aliceKey); !strings.Contains(list, "unit\tsuccess") {
71 t.Fatalf("build 2 did not pass:\n%s", list)
72 }
73 if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
74 t.Fatal("second trigger failed")
75 }
76 if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "pending") {
77 t.Fatalf("status before cancelling the duplicate:\n%s", st)
78 }
79 if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "3"); code != 0 {
80 t.Fatalf("cancel duplicate: %s", errOut)
81 }
82 if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "success") || !strings.Contains(st, "passed in build 2") {
83 t.Fatalf("status after cancelling the duplicate:\n%s", st)
84 }
85 // A running build cannot be cancelled here.
86 if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
87 t.Fatal("third trigger failed")
88 }
89 if _, _, code := inst.ssh(t, runnerKey, "", "runner", "next"); code != 0 {
90 t.Fatal("claim failed")
91 }
92 if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "4"); code != 2 || !strings.Contains(errOut, "running") {
93 t.Fatalf("cancelled a running build: exit %d %s", code, errOut)
94 }
95}
internal/control/build.go +39
@@ -33,6 +33,9 @@ func init() {
3333 Summary: "list the jobs a trigger can name",
3434 Usage: "build jobs <owner/name>", ReadOnly: true, Run: runBuildJobs})
3535
36 register(Command{Path: []string{"build", "cancel"},
37 Summary: "withdraw a queued build before a runner claims it",
38 Usage: "build cancel <owner/name> <n>", Run: runBuildCancel})
3639 register(Command{Path: []string{"build", "trigger"},
3740 Summary: "queue a job now (scheduled or not)",
3841 Usage: "build trigger <owner/name> <job>", Run: runBuildTrigger})
@@ -511,3 +514,39 @@ func QueueBranchBuilds(
511514 }
512515 }
513516}
517
518func runBuildCancel(c *Ctx, args []string) int {
519 repo, b, code := buildRef(c, args)
520 if code >= 0 {
521 return code
522 }
523 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
524 if err != nil {
525 return c.fail(protocol.ExitFailure, "%v", err)
526 }
527 if !policy.CanWrite(c.User, repo, grant) {
528 return c.fail(protocol.ExitDenied, "cancelling a build needs write access to %s", repo.Path())
529 }
530 if b.Status != "pending" {
531 return c.fail(protocol.ExitUsage, "build %d is %s; only a queued build can be cancelled", b.Number, b.Status)
532 }
533 if err := c.Store.CancelBuild(b.ID); err != nil {
534 return c.fail(protocol.ExitFailure, "%v", err)
535 }
536 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("cancelled by %s before a runner claimed it\n", c.User.Username)))
537 // The queued status replaced whatever the commit had for this job. If
538 // the commit passed the job on another ref, that result stands again;
539 // otherwise the context says it was withdrawn.
540 if prev, ok, err := c.Store.SuccessBuildFor(repo.ID, b.SHA, b.Job); err == nil && ok {
541 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
542 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
543 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
544 } else {
545 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
546 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "error", "cancelled", url, c.User.ID)
547 }
548 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
549 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled"}, func(w io.Writer) {
550 fmt.Fprintf(w, "cancelled %s build %d (%s)\n", repo.Path(), b.Number, b.Job)
551 })
552}
internal/httpd/badge.go +6 −5
@@ -13,11 +13,12 @@ import (
1313
1414// badgeColors are the shield fills per build state.
1515var badgeColors = map[string]string{
16 "success": "#2da44e",
17 "failure": "#cf222e",
18 "running": "#bf8700",
19 "pending": "#bf8700",
20 "unknown": "#6b7280",
16 "success": "#2da44e",
17 "failure": "#cf222e",
18 "running": "#bf8700",
19 "pending": "#bf8700",
20 "cancelled": "#6b7280",
21 "unknown": "#6b7280",
2122}
2223
2324// badgeWidth approximates Verdana 11px advance so the pill fits its text
internal/store/builds.go +26
@@ -290,3 +290,29 @@ func (b Build) Elapsed() time.Duration {
290290 }
291291 return 0
292292}
293
294// CancelBuild withdraws a build that no runner has claimed. A running
295// build is the runner's to finish; cancelling it here would leave the
296// runner reporting on a row that says otherwise.
297func (s *Store) CancelBuild(id int64) error {
298 res, err := s.DB.Exec(`UPDATE builds SET status = 'cancelled',
299 finished_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ? AND status = 'pending'`, id)
300 if err != nil {
301 return err
302 }
303 if n, _ := res.RowsAffected(); n == 0 {
304 return ErrNotFound
305 }
306 return nil
307}
308
309// SuccessBuildFor finds a passed build of the commit for the job, on any
310// ref: what a cancelled duplicate can point back at.
311func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) {
312 b, err := scanBuild(s.DB.QueryRow(buildSelect+
313 " WHERE repo_id = ? AND sha = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job))
314 if errors.Is(err, sql.ErrNoRows) {
315 return Build{}, false, nil
316 }
317 return b, err == nil, err
318}
internal/web/static/style.css +1
@@ -1019,6 +1019,7 @@ table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: under
10191019.badge.check-success { --chip: var(--ok); }
10201020.badge.check-pending { --chip: var(--warn); }
10211021.badge.check-failure, .badge.check-error { --chip: var(--bad); }
1022.badge.check-cancelled, .chip.check-cancelled { --chip: var(--neutral); }
10221023.badge.check-running, .chip.check-running { --chip: var(--warn); }
10231024.chip.check-success { --chip: var(--ok); }
10241025.chip.check-pending { --chip: var(--warn); }