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 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/ci.go +101−27

@@ -3,11 +3,13 @@ package server
3 3 import (
4 4 "context"
5 5 "fmt"
6 "sort"
6 7 "strings"
7 8 "time"
8 9
9 10 "github.com/rodent-software/vole/ci"
10 11 "github.com/rodent-software/vole/ci/runs"
12 "github.com/rodent-software/vole/core/git"
11 13 "github.com/rodent-software/vole/core/id"
12 14 "github.com/rodent-software/vole/forge/access"
13 15 "github.com/rodent-software/vole/forge/repo"
@@ -55,6 +57,10 @@ func (s *Server) watchSpool(ctx context.Context) {
55 57 // opts into it with on.push.tags. A tag has no branch, and a branch has no tag, which
56 58 // is why the two are carried separately rather than as one "ref".
57 59 func (s *Server) enqueue(ev runs.Event) {
60 if ev.Ref == metadata.Ref {
61 s.processMetadataRange(ev.Repo, ev.Old, ev.New)
62 return
63 }
58 64 var branch, tag string
59 65 switch {
60 66 case strings.HasPrefix(ev.Ref, "refs/heads/"):
@@ -106,24 +112,56 @@ func (s *Server) enqueue(ev runs.Event) {
106 112 annotated = kind == "tag"
107 113 }
108 114 }
109 s.queue(m, ev.Repo, ev.Ref, branch, tag, commit, annotated, true)
115 changed, err := changedPaths(m.Git(), ev.Old, commit)
116 if err != nil {
117 s.Log.Warn("ci: cannot read the paths a push changed", "repo", ev.Repo, "ref", ev.Ref, "err", err)
118 return
119 }
120 s.queue(m, ev.Repo, queueSpec{
121 ref: ev.Ref, branch: branch, tag: tag, commit: commit, annotated: annotated,
122 deployPages: true, push: true, cancelSuperseded: true,
123 trigger: "push", action: "pushed", changed: changed,
124 })
110 125 }
111 126
112 // queue pins and enqueues the jobs a commit owes. The two callers are a push and the
113 // combined-result reconciler; they differ in the branch a workflow filter sees, in
114 // whether a tag makes this a release, and in whether the job may deploy the site.
115 // Nothing else about a run depends on how it was requested.
116 func (s *Server) queue(m *repo.Meta, repoName, ref, branch, tag, commit string, annotated, deployPages bool) int {
117 wfs, problems := ci.At(m.Git(), commit)
118 s.Log.Debug("ci: examined commit", "repo", repoName, "ref", ref, "commit", commit, "workflows", len(wfs), "problems", len(problems))
127 // queueSpec is one request to queue the jobs a commit owes. A push and an event
128 // differ in what selects a workflow and in the event pinned with each run; nothing
129 // else about a run depends on how it was requested.
130 type queueSpec struct {
131 ref string
132 branch string
133 tag string
134 commit string
135 annotated bool
136 // deployPages says whether a job that declares pages publishes the site. A push
137 // does; a combined result does not, and a metadata event does only on its base
138 // branch.
139 deployPages bool
140 // push selects push triggers (branch, tag, path filters). When false, trigger
141 // and action select an event trigger.
142 push bool
143 trigger string
144 action string
145 payload string
146 occurrence string
147 changed []string
148 cancelSuperseded bool
149 }
150
151 // queue pins and enqueues the jobs a commit owes. The callers are a push, the
152 // combined-result reconciler, and the event triggers; they differ in what selects a
153 // workflow and in the event pinned with the run.
154 func (s *Server) queue(m *repo.Meta, repoName string, spec queueSpec) int {
155 wfs, problems := ci.At(m.Git(), spec.commit)
156 s.Log.Debug("ci: examined commit", "repo", repoName, "ref", spec.ref, "commit", spec.commit, "workflows", len(wfs), "problems", len(problems))
119 157 if len(problems) > 0 {
120 158 // pre-receive rejects these, so reaching here means a workflow was already
121 159 // broken: queue nothing rather than guess.
122 160 s.Log.Warn("ci: workflows in a pushed commit do not validate", "repo", repoName, "problems", len(problems))
123 161 return 0
124 162 }
125 // What this push owes: one run per job of every workflow that applies to it. The
126 // list is built before anything is queued, because a push that owes nothing must
163 // What this event owes: one run per job of every workflow that applies to it. The
164 // list is built before anything is queued, because an event that owes nothing must
127 165 // not cancel the previous tip's builds. A job's runs-on labels travel beside the
128 166 // run rather than inside it: they are lease policy, and Enqueue takes them as its
129 167 // own argument so a caller holding the job cannot forget them.
@@ -133,28 +171,33 @@ func (s *Server) queue(m *repo.Meta, repoName, ref, branch, tag, commit string,
133 171 }
134 172 var owing []owedRun
135 173 for _, wf := range wfs {
136 switch {
137 case branch != "" && !wf.Applies(branch):
138 continue
139 case tag != "" && !wf.AppliesToTag(tag):
174 if spec.push {
175 if !wf.AppliesToPush(spec.branch, spec.tag, spec.changed) {
176 continue
177 }
178 } else if !wf.AppliesToEvent(spec.trigger, spec.action) {
140 179 continue
141 180 }
142 181 for _, job := range wf.Jobs {
143 182 site := ""
144 if deployPages {
183 if spec.deployPages {
145 184 site = pagesPath(job)
146 185 }
147 186 run := runs.Run{
148 187 Repo: repoName,
149 Ref: ref,
150 Branch: branch,
151 Tag: tag,
152 TagAnnotated: annotated,
153 SHA: commit,
188 Ref: spec.ref,
189 Branch: spec.branch,
190 Tag: spec.tag,
191 TagAnnotated: spec.annotated,
192 SHA: spec.commit,
154 193 Workflow: wf.Path,
155 194 WorkflowSHA: wf.BlobSHA,
156 195 WorkflowName: wf.Name,
157 196 WorkflowDescription: wf.Description,
197 Trigger: spec.trigger,
198 TriggerAction: spec.action,
199 EventPayload: spec.payload,
200 Occurrence: spec.occurrence,
158 201 Job: job.ID,
159 202 JobName: job.Name,
160 203 JobDescription: job.Description,
@@ -180,12 +223,13 @@ func (s *Server) queue(m *repo.Meta, repoName, ref, branch, tag, commit string,
180 223 // minute must not build the first commit twice. Running jobs are left alone, and
181 224 // the cancellation is logged because a run that stopped for a reason nobody can
182 225 // find is the same as a run that vanished. A candidate ref names one commit, so
183 // this is a no-op for combined results; the cancellation that matters there is
184 // the stale-ref cleanup.
185 if n, err := s.Runs.CancelSuperseded(repoName, ref, commit, time.Now().UTC()); err != nil {
186 s.Log.Warn("ci: cannot cancel superseded runs", "repo", repoName, "ref", ref, "err", err)
187 } else if n > 0 {
188 s.Log.Info("ci: canceled superseded runs", "repo", repoName, "ref", ref, "runs", n)
226 // this is a no-op for combined results; a metadata event never cancels anything.
227 if spec.cancelSuperseded {
228 if n, err := s.Runs.CancelSuperseded(repoName, spec.ref, spec.commit, time.Now().UTC()); err != nil {
229 s.Log.Warn("ci: cannot cancel superseded runs", "repo", repoName, "ref", spec.ref, "err", err)
230 } else if n > 0 {
231 s.Log.Info("ci: canceled superseded runs", "repo", repoName, "ref", spec.ref, "runs", n)
232 }
189 233 }
190 234 queued := 0
191 235 for _, owed := range owing {
@@ -197,12 +241,42 @@ func (s *Server) queue(m *repo.Meta, repoName, ref, branch, tag, commit string,
197 241 }
198 242 if created {
199 243 queued++
200 s.Log.Info("ci: queued", "repo", repoName, "job", run.Job, "sha", run.SHA[:8], "run", run.ID)
244 s.Log.Info("ci: queued", "repo", repoName, "job", run.Job, "sha", run.SHA[:8], "run", run.ID, "trigger", run.Trigger)
201 245 }
202 246 }
203 247 return queued
204 248 }
205 249
250 // changedPaths lists the paths a ref update touched. A new ref has no old tree, so
251 // every path in the new commit is new; an existing ref is compared tree to tree, so
252 // a force-push is measured by what actually differs.
253 func changedPaths(g *git.Repo, old, new string) ([]string, error) {
254 if new == "" || new == git.ZeroSHA {
255 return nil, nil
256 }
257 if old == "" || old == git.ZeroSHA {
258 entries, err := g.LsTreeR(new)
259 if err != nil {
260 return nil, err
261 }
262 paths := make([]string, 0, len(entries))
263 for _, entry := range entries {
264 paths = append(paths, entry.Name)
265 }
266 return paths, nil
267 }
268 status, err := g.DiffStatus(old, new)
269 if err != nil {
270 return nil, err
271 }
272 paths := make([]string, 0, len(status))
273 for p := range status {
274 paths = append(paths, p)
275 }
276 sort.Strings(paths)
277 return paths, nil
278 }
279
206 280 // reconcileSeconds is how often the queue is checked against the refs. The spool
207 281 // delivers a push in well under a second; this is the safety net that makes the
208 282 // promise eventually true when the spool file was never written.