Commit 3eb692a4a5

3eb692a4a5eb1e675b8f29fa7702d143eaf337b3

parent: 9da25671b7

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-05 19:03 UTC

web: cancel a queued or running build from its page

build cancel accepts a queued build and a running one — its own summary
line undersold that, saying only "before a runner claims it" — so the
page offers the control while a build is pending or running, and hides
it once a build reaches a terminal state. Cancelling a running build is
not instant, so the button says so: the runner stops at its next check.
The command decides for real: a stale or repeated post against a build
that can no longer be cancelled shows the refusal instead of a broken
page.

Closes #162

Layout: unified · split

e2e/buildcancelweb_test.go added +124
@@ -0,0 +1,124 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestBuildCancelWeb covers cancelling a build from its page (#162). The
12// control is offered for anything the command accepts — queued or running —
13// and hidden once a build reaches a terminal state; the command decides for
14// real, so a stale or repeated post against a build that is no longer
15// cancellable shows the refusal rather than a broken page.
16func TestBuildCancelWeb(t *testing.T) {
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
18 aliceKey := inst.newKey(t, "alice")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21
22 // ci is an ordinary account; its runner key is self-added with
23 // --scope runner, which confines it to the runner protocol and
24 // read-only git rather than reaching for admin.
25 ciKey := inst.newKey(t, "ci")
26 inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub")
27 runnerKey := inst.newKey(t, "ci-runner")
28 pub, _ := os.ReadFile(runnerKey + ".pub")
29 if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 {
30 t.Fatalf("keys add --scope runner: %s", errOut)
31 }
32 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
33 t.Fatalf("repo create: %s", errOut)
34 }
35 work := t.TempDir()
36 env := inst.gitEnv(aliceKey)
37 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
38 dir := filepath.Join(work, "w")
39 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
40 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo fine\n"), 0o644)
41 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
42 mustGit(t, dir, env, "add", ".")
43 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
44 mustGit(t, dir, env, "push", "-q", "origin", "main")
45
46 alice := inst.login(t, aliceKey)
47 build1 := inst.base() + "/alice/app/builds/1"
48
49 // Build 1 is queued: the control is on the page, for someone with
50 // write access.
51 _, body := browserGet(t, alice, build1)
52 if !strings.Contains(body, `action="/alice/app/builds/1/cancel"`) {
53 t.Fatalf("no cancel control on a queued build:\n%s", body)
54 }
55 // A reader gets no control.
56 _, anon := browserGet(t, newBrowser(t), build1)
57 if strings.Contains(anon, "/cancel") {
58 t.Fatal("anonymous visitor sees the cancel control")
59 }
60
61 // Cancelling from the page lands where the CLI sees it.
62 if status, _ := browserPost(t, alice, build1+"/cancel", url.Values{}); status != 200 {
63 t.Fatalf("cancel post: %d", status)
64 }
65 if out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app"); !strings.Contains(out, "unit\tcancelled") {
66 t.Fatalf("build not cancelled: %s", out)
67 }
68 // Cancelled, so the control is gone.
69 if _, body = browserGet(t, alice, build1); strings.Contains(body, "/cancel") {
70 t.Fatalf("cancel control still on a cancelled build:\n%s", body)
71 }
72
73 // Build 2: queued, then claimed by a runner without one actually
74 // running any steps — enough to move it to "running".
75 if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 {
76 t.Fatalf("trigger: %s", errOut)
77 }
78 if out, _, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/app"); code != 0 || strings.Contains(out, "no pending") {
79 t.Fatalf("runner claim: %s", out)
80 }
81 build2 := inst.base() + "/alice/app/builds/2"
82 _, body = browserGet(t, alice, build2)
83 if !strings.Contains(body, "running") {
84 t.Fatalf("build 2 not running:\n%s", body)
85 }
86 // The command accepts cancelling a running build too — that is the
87 // case #162 was filed for, a run going nowhere — so the control stays
88 // up, worded so cancelling does not read as instant.
89 if !strings.Contains(body, `action="/alice/app/builds/2/cancel"`) {
90 t.Fatalf("no cancel control on a running build:\n%s", body)
91 }
92 if !strings.Contains(body, "next check") {
93 t.Fatalf("cancel control does not warn it is not instant:\n%s", body)
94 }
95
96 // Cancelling the running build from the page lands where the CLI sees
97 // it, including the runner-facing wording that it stops at its next
98 // check rather than right away.
99 if status, _ := browserPost(t, alice, build2+"/cancel", url.Values{}); status != 200 {
100 t.Fatalf("cancel running build: %d", status)
101 }
102 out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
103 if !strings.Contains(out, "unit\tcancelled") {
104 t.Fatalf("running build not cancelled: %s", out)
105 }
106 log, _, _ := inst.ssh(t, aliceKey, "", "build", "log", "alice/app", "2")
107 if !strings.Contains(log, "cancelled by alice while running") {
108 t.Fatalf("log missing the while-running cancellation: %s", log)
109 }
110 // Cancelled, so the control is gone here too.
111 if _, body = browserGet(t, alice, build2); strings.Contains(body, "/cancel") {
112 t.Fatalf("cancel control still on a cancelled build:\n%s", body)
113 }
114
115 // A stale or repeated post against a build that can no longer be
116 // cancelled is refused, and the page says so rather than breaking.
117 status, body := browserPost(t, alice, build1+"/cancel", url.Values{})
118 if status != 200 {
119 t.Fatalf("re-cancel post: %d", status)
120 }
121 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "cancelled") {
122 t.Fatalf("refusal not surfaced on the page:\n%s", body)
123 }
124}
internal/httpd/buildpages_test.go +9 −2
@@ -55,8 +55,10 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
55 var sb strings.Builder 55 var sb strings.Builder
56 err := web.Render(&sb, "build.html", struct { 56 err := web.Render(&sb, "build.html", struct {
57 repoPage 57 repoPage
58 Build control.BuildOut 58 Build control.BuildOut
59 Log string 59 Log string
60 CanWrite bool
61 Notice string
60 }{ 62 }{
61 testRepoPage(), 63 testRepoPage(),
62 control.BuildOut{ 64 control.BuildOut{
@@ -65,6 +67,7 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
65 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z", 67 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z",
66 }, 68 },
67 "step 1 ok", 69 "step 1 ok",
70 true, "",
68 }) 71 })
69 if err != nil { 72 if err != nil {
70 t.Fatalf("render: %v", err) 73 t.Fatalf("render: %v", err)
@@ -78,4 +81,8 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
78 t.Errorf("build.html missing %q", want) 81 t.Errorf("build.html missing %q", want)
79 } 82 }
80 } 83 }
84 // A finished build offers no cancel control, even to a writer.
85 if strings.Contains(out, "/cancel") {
86 t.Error("build.html offers cancel on a finished build")
87 }
81} 88}
internal/httpd/builds.go +5 −3
@@ -54,7 +54,9 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
54 54
55 s.render(w, "build.html", struct { 55 s.render(w, "build.html", struct {
56 repoPage 56 repoPage
57 Build control.BuildOut 57 Build control.BuildOut
58 Log string 58 Log string
59 }{p, b, log}) 59 CanWrite bool
60 Notice string
61 }{p, b, log, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
60} 62}
internal/httpd/releaseactions.go +14
@@ -53,3 +53,17 @@ func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u st
53 _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job}) 53 _, msg, code := s.runControlCode(u, []string{"build", "trigger", repo, job})
54 s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) }) 54 s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "builds", msg) })
55} 55}
56
57// buildCancelSubmit withdraws a build. The page only offers the control
58// while a build is still queued; the command decides for real, so a stale
59// page posting against a build that has since finished sees the refusal
60// instead of a silent no-op.
61func (s *Server) buildCancelSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
62 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
63 n := r.PathValue("n")
64 back := func(w http.ResponseWriter, r *http.Request, msg string) {
65 s.backTo(w, r, "builds/"+n, msg)
66 }
67 _, msg, code := s.runControlCode(u, []string{"build", "cancel", repo, n})
68 s.done(w, r, code, msg, back)
69}
internal/httpd/routes.go +2
@@ -150,6 +150,8 @@ func (s *Server) Routes() []Route {
150 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, 150 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
151 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, 151 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
152 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, 152 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))},
153 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true,
154 Handler: s.checkOrigin(s.requireUser(s.buildCancelSubmit))},
153 Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", 155 Route{Method: "GET", Pattern: "/{owner}/{repo}/settings",
154 Handler: s.requireUser(s.settingsForm)}, 156 Handler: s.requireUser(s.settingsForm)},
155 Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, 157 Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true,
internal/web/templates/build.html +7
@@ -1,7 +1,14 @@
1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
3<div class="headrow"> 4<div class="headrow">
4<h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1> 5<h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1>
6<span class="spacer"></span>
7{{if and .CanWrite (or (eq .Build.Status "pending") (eq .Build.Status "running"))}}
8<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds/{{.Build.Number}}/cancel" class="actions">
9 <button type="submit">{{if eq .Build.Status "running"}}Cancel (stops at the runner's next check){{else}}Cancel{{end}}</button>
10</form>
11{{end}}
5</div> 12</div>
6<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p> 13<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p>
7{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} 14{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}