Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Add the workflow list and run cancel/rerun actions

0b26547e3ead573cfa604260d5f0bde83bcf18b0 · Keenan Nemetz · 15d ago · parent 455835e7

Add the workflow list and run cancel/rerun actions

The Runs tab now links to /r/{name}/workflows, which reads the default branch with the same validator the push boundary uses and shows triggers, jobs, runner labels, image, steps, outputs, and each job's latest run.

Maintainers can cancel a queued run or rerun a finished one. Rerun is another attempt on the same queue row, not a second row: the queue deliberately has one run per (repo, ref, sha, workflow, job), and attempts already carry their own logs and attestations. Running jobs are not interrupted. Both actions carry CSRF and are audited.

server/workflows_test.go +105

@@ -0,0 +1,105 @@
1 package server_test
2
3 import (
4 "net/url"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/rodent-software/vole/ci/runs"
10 )
11
12 func TestWorkflowListAndRunActions(t *testing.T) {
13 t.Parallel()
14 f := newFixture(t)
15 f.publish(f.repo)
16 work := workRepo(t, map[string]string{
17 ".vole/workflows/ci.yaml": `version: 1
18 on:
19 push:
20 branches: [main]
21 jobs:
22 test:
23 image: alpine:3.22
24 steps:
25 - name: build
26 run: go build ./...
27 `,
28 })
29 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
30 t.Fatalf("push: %s", errs)
31 }
32
33 enqueue := func(id, status string) {
34 run := runs.Run{
35 ID: id, Repo: f.repo, Ref: "refs/heads/main", Branch: "main",
36 SHA: strings.Repeat("a", 40), Workflow: ".vole/workflows/ci.yaml",
37 WorkflowSHA: "bbbb", Job: "test", Status: status, Created: time.Now().UTC(),
38 Steps: []runs.Step{{Name: "build", Run: "go build ./...", Timeout: "1m0s"}},
39 }
40 if status == runs.StatusSuccess {
41 run.Finished = time.Now().UTC()
42 run.Attempts = 1
43 }
44 if _, err := f.srv.Runs.Enqueue(run, []string{"linux"}); err != nil {
45 t.Fatal(err)
46 }
47 }
48 enqueue("01M5R8XR12EZ5WYJDM0F1928D1", runs.StatusQueued)
49
50 code, body := f.get("/r/" + f.repo + "/workflows")
51 if code != 200 {
52 t.Fatalf("workflows page = %d", code)
53 }
54 for _, want := range []string{".vole/workflows/ci.yaml", "test", "alpine:3.22", "any runner", "queued"} {
55 if !strings.Contains(body, want) {
56 t.Fatalf("workflows page does not show %q:\n%s", want, body)
57 }
58 }
59
60 sess, cookie := f.browserSession(t, f.ownerID)
61 resp, body := f.browserGet(t, "/r/"+f.repo+"/runs/01M5R8XR12EZ5WYJDM0F1928D1", cookie)
62 if resp.StatusCode != 200 || !strings.Contains(body, "Cancel queued run") || strings.Contains(body, ">Rerun<") {
63 t.Fatalf("queued run actions = %d:\n%s", resp.StatusCode, body)
64 }
65
66 resp, _ = f.browserPost(t, "/r/"+f.repo+"/runs/01M5R8XR12EZ5WYJDM0F1928D1/cancel", cookie,
67 url.Values{"csrf": {sess.CSRF}})
68 if resp.StatusCode != 303 {
69 t.Fatalf("cancel = %d", resp.StatusCode)
70 }
71 run, err := f.srv.Runs.Get("01M5R8XR12EZ5WYJDM0F1928D1")
72 if err != nil || run.Status != runs.StatusCanceled {
73 t.Fatalf("canceled run = %+v, %v", run, err)
74 }
75 if resp, body := f.browserGet(t, "/r/"+f.repo+"/runs/01M5R8XR12EZ5WYJDM0F1928D1", cookie); resp.StatusCode != 200 || !strings.Contains(body, "Rerun") {
76 t.Fatalf("canceled run page = %d:\n%s", resp.StatusCode, body)
77 }
78
79 resp, _ = f.browserPost(t, "/r/"+f.repo+"/runs/01M5R8XR12EZ5WYJDM0F1928D1/rerun", cookie,
80 url.Values{"csrf": {sess.CSRF}})
81 if resp.StatusCode != 303 {
82 t.Fatalf("rerun = %d", resp.StatusCode)
83 }
84 if resp.Header.Get("Location") != "/r/"+f.repo+"/runs/01M5R8XR12EZ5WYJDM0F1928D1" {
85 t.Fatalf("rerun location = %q", resp.Header.Get("Location"))
86 }
87 next, err := f.srv.Runs.Get("01M5R8XR12EZ5WYJDM0F1928D1")
88 if err != nil || next.Status != runs.StatusQueued || next.Job != "test" || next.Workflow != ".vole/workflows/ci.yaml" {
89 t.Fatalf("rerun = %+v, %v", next, err)
90 }
91 labels, err := f.srv.Runs.Labels(next.ID)
92 if err != nil || strings.Join(labels, ",") != "linux" {
93 t.Fatalf("rerun labels = %v, %v", labels, err)
94 }
95
96 resp, _ = f.browserPost(t, "/r/"+f.repo+"/runs/"+next.ID+"/rerun", cookie,
97 url.Values{"csrf": {sess.CSRF}})
98 if resp.StatusCode != 400 {
99 t.Fatalf("rerun queued = %d, want 400", resp.StatusCode)
100 }
101 resp, _ = f.browserPost(t, "/r/"+f.repo+"/runs/"+next.ID+"/cancel", cookie, url.Values{})
102 if resp.StatusCode != 403 {
103 t.Fatalf("cancel without CSRF = %d, want 403", resp.StatusCode)
104 }
105 }