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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Add event, schedule and manual workflow triggers

eb07ff5696e0d83201215966e3ea1e6a24467581 · Keenan Nemetz · 8d ago · parent 1cde8b9e

Add event, schedule and manual workflow triggers

Workflows can now react to change requests, issues, comments, cron
schedules and manual runs, and a push trigger can filter by the paths it
changed. Branch and tag patterns gain ! exclusion.

Event runs are pinned with a JSON payload and the runner exposes it as
VOLE_EVENT, VOLE_EVENT_ACTION and VOLE_EVENT_PATH. Change-request runs
check out the base tip and carry the head and changed files, so an event
never executes an unapproved contributor commit.

Workflow dispatch resolves typed inputs and is available from the
workflows page and `vole workflow dispatch`. Deduplication now includes
an occurrence so the same commit can run for a later event or schedule
slot while a push remains idempotent.

Part of: #138

server/events_test.go +201

@@ -0,0 +1,201 @@
1 package server_test
2
3 import (
4 "encoding/json"
5 "net/url"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/rodent-software/vole/ci/runs"
11 )
12
13 func TestIssueAndCommentTriggersQueueRuns(t *testing.T) {
14 t.Parallel()
15 f := newFixture(t)
16 f.publish(f.repo)
17 work, _ := f.cloneWithChange(t, workflowFile(`version: 1
18 on:
19 issue:
20 comment:
21 jobs:
22 notify:
23 image: alpine:3.22
24 steps:
25 - run: cat "$VOLE_EVENT_PATH"
26 `), "ci")
27 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
28 t.Fatalf("push: %s", errs)
29 }
30
31 if _, errs, err := f.cli(work, "issue", "new", "--title", "Trigger me", "-m", "Issue body"); err != nil {
32 t.Fatalf("issue new: %v\n%s", err, errs)
33 }
34 opened := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool {
35 return r.Trigger == "issue" && r.TriggerAction == "opened"
36 })
37 if opened.EventPayload == "" || !json.Valid([]byte(opened.EventPayload)) {
38 t.Fatalf("issue payload = %q", opened.EventPayload)
39 }
40 if !strings.Contains(opened.EventPayload, `"title":"Trigger me"`) ||
41 !strings.Contains(opened.EventPayload, `"body":"Issue body`) {
42 t.Fatalf("issue payload does not carry the issue:\n%s", opened.EventPayload)
43 }
44
45 if _, errs, err := f.cli(work, "issue", "comment", "#1", "-m", "A comment"); err != nil {
46 t.Fatalf("issue comment: %v\n%s", err, errs)
47 }
48 comment := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool {
49 return r.Trigger == "comment" && r.TriggerAction == "created"
50 })
51 if !strings.Contains(comment.EventPayload, `"body":"A comment`) ||
52 !strings.Contains(comment.EventPayload, `"comment_on":"issue"`) {
53 t.Fatalf("comment payload:\n%s", comment.EventPayload)
54 }
55 }
56
57 func TestChangeRequestTriggerCarriesBaseAndChangedFiles(t *testing.T) {
58 t.Parallel()
59 f := newFixture(t)
60 f.publish(f.repo)
61 main := workRepo(t, workflowFile(`version: 1
62 on:
63 change-request:
64 jobs:
65 describe:
66 image: alpine:3.22
67 steps:
68 - run: echo "$VOLE_EVENT_ACTION"
69 `))
70 if _, errs, err := f.git(main, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
71 t.Fatalf("push main: %s", errs)
72 }
73
74 feature, _ := f.cloneWithChange(t, map[string]string{"feature.txt": "a change\n"}, "feature")
75 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
76 t.Fatalf("push feature: %s", errs)
77 }
78 if _, errs, err := f.cli(feature, "change", "new", "--title", "A feature", "--base", "main", "--head", "feature", "-m", "body"); err != nil {
79 t.Fatalf("change new: %v\n%s", err, errs)
80 }
81
82 run := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool {
83 return r.Trigger == "change_request" && r.TriggerAction == "opened"
84 })
85 if run.Branch != "main" {
86 t.Fatalf("event run branch = %q, want the base", run.Branch)
87 }
88 var payload struct {
89 BaseSHA string `json:"base_sha"`
90 HeadSHA string `json:"head_sha"`
91 ChangedFiles []string `json:"changed_files"`
92 }
93 if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
94 t.Fatalf("payload: %v\n%s", err, run.EventPayload)
95 }
96 if payload.BaseSHA == "" || payload.HeadSHA == "" || payload.BaseSHA == payload.HeadSHA {
97 t.Fatalf("base/head = %q/%q", payload.BaseSHA, payload.HeadSHA)
98 }
99 found := false
100 for _, path := range payload.ChangedFiles {
101 if path == "feature.txt" {
102 found = true
103 }
104 }
105 if !found {
106 t.Fatalf("changed files = %v", payload.ChangedFiles)
107 }
108 }
109
110 func TestWorkflowDispatchCarriesInputs(t *testing.T) {
111 t.Parallel()
112 f := newFixture(t)
113 f.publish(f.repo)
114 work := workRepo(t, workflowFile(`version: 1
115 on:
116 workflow-dispatch:
117 inputs:
118 environment:
119 type: choice
120 default: staging
121 options: [staging, production]
122 verbose:
123 type: boolean
124 jobs:
125 deploy:
126 image: alpine:3.22
127 steps:
128 - run: cat "$VOLE_EVENT_PATH"
129 `))
130 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
131 t.Fatalf("push: %s", errs)
132 }
133 out, errs, err := f.cli(work, "workflow", "dispatch", "--remote", f.sshURL(f.repo),
134 "--workflow", ".vole/workflows/ci.yaml",
135 "--input", "environment=production", "--input", "verbose=true")
136 if err != nil {
137 t.Fatalf("dispatch: %v\n%s", err, errs)
138 }
139 if !strings.Contains(out, "queued 1 run") {
140 t.Fatalf("dispatch output = %q", out)
141 }
142 run := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool {
143 return r.Trigger == "workflow_dispatch" && r.TriggerAction == "dispatched"
144 })
145 var payload struct {
146 Inputs map[string]string `json:"inputs"`
147 }
148 if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
149 t.Fatalf("payload: %v\n%s", err, run.EventPayload)
150 }
151 if payload.Inputs["environment"] != "production" || payload.Inputs["verbose"] != "true" {
152 t.Fatalf("inputs = %v", payload.Inputs)
153 }
154 }
155
156 func TestWorkflowDispatchPageRendersTheInputs(t *testing.T) {
157 t.Parallel()
158 f := newFixture(t)
159 f.publish(f.repo)
160 work := workRepo(t, workflowFile(`version: 1
161 on:
162 workflow-dispatch:
163 inputs:
164 environment:
165 description: Where to deploy
166 type: choice
167 default: staging
168 options: [staging, production]
169 jobs:
170 deploy:
171 image: alpine:3.22
172 steps:
173 - run: echo deploy
174 `))
175 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
176 t.Fatalf("push: %s", errs)
177 }
178 sess, cookie := f.browserSession(t, f.ownerID)
179 resp, body := f.browserGet(t, "/r/"+f.repo+"/workflows/dispatch?workflow=.vole/workflows/ci.yaml", cookie)
180 if resp.StatusCode != 200 {
181 t.Fatalf("dispatch page = %d\n%s", resp.StatusCode, body)
182 }
183 for _, want := range []string{"Run workflow", "environment", "production", ".vole/workflows/ci.yaml"} {
184 if !strings.Contains(body, want) {
185 t.Fatalf("dispatch page does not show %q:\n%s", want, body)
186 }
187 }
188 resp, _ = f.browserPost(t, "/r/"+f.repo+"/workflows/dispatch", cookie, url.Values{
189 "csrf": {sess.CSRF}, "workflow": {".vole/workflows/ci.yaml"},
190 "ref": {"main"}, "environment": {"production"},
191 })
192 if resp.StatusCode != 303 {
193 t.Fatalf("dispatch post = %d", resp.StatusCode)
194 }
195 run := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool {
196 return r.Trigger == "workflow_dispatch" && strings.Contains(r.EventPayload, `"environment":"production"`)
197 })
198 if run.Branch != "main" {
199 t.Fatalf("dispatch branch = %q", run.Branch)
200 }
201 }