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

ci/triggers_test.go +154

@@ -0,0 +1,154 @@
1 package ci
2
3 import (
4 "strings"
5 "testing"
6 "time"
7 )
8
9 const triggerWorkflow = `version: 1
10 on:
11 push:
12 branches: ["*", "!main"]
13 paths: ["src/**", "cmd/**"]
14 paths-ignore: ["**/*.md"]
15 change-request:
16 types: [opened, ready]
17 issue:
18 comment:
19 types: [created, deleted]
20 schedule:
21 - cron: "0 3 * * 1-5"
22 workflow-dispatch:
23 inputs:
24 environment:
25 description: Where to deploy
26 type: choice
27 required: true
28 default: staging
29 options: [staging, production]
30 verbose:
31 type: boolean
32 count:
33 type: number
34 default: 2
35 jobs:
36 build:
37 image: alpine:3.22
38 steps:
39 - run: echo hi
40 `
41
42 func TestTriggerSchemaParses(t *testing.T) {
43 t.Parallel()
44 wf, problems := Parse(".vole/workflows/triggers.yaml", triggerWorkflow)
45 if len(problems) > 0 {
46 t.Fatalf("valid triggers rejected: %v", problems)
47 }
48 if wf.Applies("main") {
49 t.Fatal("a later !main did not exclude main")
50 }
51 if !wf.Applies("dev") {
52 t.Fatal("branches did not keep the wider * match")
53 }
54 if !wf.AppliesToPush("dev", "", []string{"src/main.go"}) {
55 t.Fatal("a path under src did not match")
56 }
57 if wf.AppliesToPush("dev", "", []string{"src/readme.md"}) {
58 t.Fatal("an ignored path matched")
59 }
60 if wf.AppliesToPush("dev", "", []string{"docs/guide.md"}) {
61 t.Fatal("a path outside paths matched")
62 }
63 if !wf.AppliesToEvent("change_request", "opened") || wf.AppliesToEvent("change_request", "updated") {
64 t.Fatalf("change_request actions = %v", wf.ChangeRequest.Types)
65 }
66 if !wf.AppliesToEvent("issue", "opened") {
67 t.Fatalf("issue default actions = %v", wf.Issues.Types)
68 }
69 if !wf.AppliesToEvent("comment", "deleted") || wf.AppliesToEvent("comment", "edited") {
70 t.Fatalf("comment actions = %v", wf.Comments.Types)
71 }
72 if !wf.AppliesToEvent("schedule", "scheduled") || !wf.AppliesToEvent("workflow_dispatch", "dispatched") {
73 t.Fatal("schedule or dispatch trigger did not match")
74 }
75 if len(wf.Schedules) != 1 || wf.Schedules[0].Cron != "0 3 * * 1-5" {
76 t.Fatalf("schedules = %+v", wf.Schedules)
77 }
78 }
79
80 func TestDispatchInputsResolve(t *testing.T) {
81 t.Parallel()
82 wf, problems := Parse(".vole/workflows/triggers.yaml", triggerWorkflow)
83 if len(problems) > 0 {
84 t.Fatalf("rejected: %v", problems)
85 }
86 values, problems := wf.ResolveDispatchInputs(map[string]string{"environment": "production"})
87 if len(problems) > 0 {
88 t.Fatalf("valid inputs rejected: %v", problems)
89 }
90 if values["environment"] != "production" || values["count"] != "2" {
91 t.Fatalf("values = %v", values)
92 }
93 if _, present := values["verbose"]; present {
94 t.Fatalf("an unset optional input became %q", values["verbose"])
95 }
96 for _, given := range []map[string]string{
97 {"environment": "elsewhere"},
98 {"environment": "staging", "unknown": "x"},
99 {"environment": "staging", "verbose": "maybe"},
100 {"environment": "staging", "count": "many"},
101 } {
102 if _, problems := wf.ResolveDispatchInputs(given); len(problems) == 0 {
103 t.Fatalf("inputs %v were accepted", given)
104 }
105 }
106 }
107
108 func TestCronMatchesMinuteAndWeekday(t *testing.T) {
109 t.Parallel()
110 cron, err := ParseCron("0 3 * * 1-5")
111 if err != nil {
112 t.Fatal(err)
113 }
114 monday := time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC) // a Monday
115 if !cron.Matches(monday) {
116 t.Fatal("03:00 on a weekday did not match")
117 }
118 if cron.Matches(monday.Add(time.Minute)) {
119 t.Fatal("03:01 matched a 03:00 schedule")
120 }
121 saturday := time.Date(2026, 11, 7, 3, 0, 0, 0, time.UTC)
122 if cron.Matches(saturday) {
123 t.Fatal("Saturday matched a Monday-Friday schedule")
124 }
125 }
126
127 func TestInvalidTriggersRejected(t *testing.T) {
128 t.Parallel()
129 base := "version: 1\non:\n"
130 jobs := "\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n"
131 cases := []struct {
132 name string
133 on string
134 want string
135 }{
136 {"unknown event action", " issue:\n types: [exploded]\n", "not an action"},
137 {"unknown trigger key", " change-request:\n actions: [opened]\n", "not a known key"},
138 {"bad cron", " schedule:\n - cron: \"0 3 * *\"\n", "5 fields"},
139 {"bad path", " push:\n paths: [\"../outside\"]\n", "must not contain"},
140 {"bad dispatch type", " workflow-dispatch:\n inputs:\n x:\n type: object\n", "not a type"},
141 {"choice without options", " workflow-dispatch:\n inputs:\n x:\n type: choice\n", "needs at least one option"},
142 }
143 for _, tc := range cases {
144 t.Run(tc.name, func(t *testing.T) {
145 _, problems := Parse(".vole/workflows/x.yaml", base+tc.on+jobs)
146 if len(problems) == 0 {
147 t.Fatalf("accepted %q", tc.on)
148 }
149 if joined := strings.Join(problems, "\n"); !strings.Contains(joined, tc.want) {
150 t.Fatalf("problems do not mention %q:\n%s", tc.want, joined)
151 }
152 })
153 }
154 }