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 | } | ||