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

ci/workflow.go +792−32

@@ -16,6 +16,7 @@ import (
16 16 "path/filepath"
17 17 "regexp"
18 18 "sort"
19 "strconv"
19 20 "strings"
20 21 "time"
21 22
@@ -48,6 +49,20 @@ const (
48 49 // MaxDescriptionBytes bounds a display description. One sentence a reader can
49 50 // repeat is enough to say what a workflow is for.
50 51 MaxDescriptionBytes = 500
52 // MaxPaths bounds the path filters a push trigger may declare. A workflow that
53 // needs more than a handful of patterns is describing a directory and should say
54 // so with a `**` pattern.
55 MaxPaths = 20
56 // MaxSchedules bounds the cron entries on one workflow. A schedule is an
57 // expectation the runner pool has to meet, not a place to keep a list.
58 MaxSchedules = 5
59 // MaxDispatchInputs bounds the inputs a manual run may declare.
60 MaxDispatchInputs = 10
61 // MaxDispatchValueBytes bounds one manual input value.
62 MaxDispatchValueBytes = 4 << 10
63 // MaxEventPayloadBytes bounds the event JSON pinned with a run. It is read as
64 // data from a mounted file, never interpolated into a step.
65 MaxEventPayloadBytes = 64 << 10
51 66 // DefaultTimeout applies to a step that does not set one. A hung step must not
52 67 // hold a runner forever.
53 68 DefaultTimeout = 10 * time.Minute
@@ -63,18 +78,37 @@ var (
63 78 // runner says it is with --label. Lowercase because a label is an identity, and
64 79 // `Linux` and `linux` being different machines is a support question nobody wants.
65 80 labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
81 // dispatchInputRE is a name that can reach a shell as a variable when a step
82 // chooses to export the inputs it reads from the event payload.
83 dispatchInputRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,31}$`)
84 // pathGlob allows the two wildcards a path filter needs and nothing that could
85 // be read as a revision or escape the tree.
86 pathGlob = regexp.MustCompile(`^[A-Za-z0-9*?\[\]!._/-]+$`)
66 87 // reservedEnv is what the sandbox itself sets. A job that could replace these
67 88 // could make the container lie about being in CI or move HOME out of the writable
68 // path, so they are refused rather than overridden.
89 // path, so they are refused rather than overridden. The event variables are set
90 // by the runner from the pinned event, never by the workflow.
69 91 reservedEnv = map[string]bool{
70 92 "CI": true,
71 93 "VOLE_CI": true,
94 "VOLE_EVENT": true,
95 "VOLE_EVENT_ACTION": true,
96 "VOLE_EVENT_PATH": true,
72 97 "GIT_TERMINAL_PROMPT": true,
73 98 "HOME": true,
74 99 "TMPDIR": true,
75 100 }
76 101 )
77 102
103 var (
104 changeRequestActions = []string{"opened", "updated", "ready", "closed", "merged"}
105 defaultChangeRequestActions = []string{"opened", "updated", "ready"}
106 issueActions = []string{"opened", "edited", "closed", "reopened"}
107 defaultIssueActions = []string{"opened"}
108 commentActions = []string{"created", "edited", "deleted"}
109 defaultCommentActions = []string{"created"}
110 )
111
78 112 // ValidLabel reports whether s can be a runner label. It is exported so the runner
79 113 // refuses a bad --label at startup with the same rule the workflow validator applies:
80 114 // a label that can never be stored would otherwise be a machine that silently never
@@ -148,6 +182,36 @@ type Pages struct {
148 182 Path string
149 183 }
150 184
185 // EventTrigger is the action list for a non-push trigger. An absent list is the
186 // trigger's default, which is documented in the reference: the common actions,
187 // not every action the server can report.
188 type EventTrigger struct {
189 Types []string
190 }
191
192 // Schedule is one cron entry. Times are UTC, and the field syntax is the usual
193 // five fields: minute, hour, day of month, month, day of week.
194 type Schedule struct {
195 Cron string
196 }
197
198 // DispatchInput is one typed input a manual run may carry. The type decides what
199 // value the server accepts; the workflow step sees the resolved value in the event
200 // payload, never in an interpolated shell string.
201 type DispatchInput struct {
202 Description string
203 Type string // string, boolean, number, choice
204 Required bool
205 Default string
206 DefaultSet bool
207 Options []string // for type choice
208 }
209
210 // Dispatch declares that a workflow can be run by hand, with typed inputs.
211 type Dispatch struct {
212 Inputs map[string]DispatchInput
213 }
214
151 215 type Workflow struct {
152 216 Path string
153 217 // Name is the display name from `name:`, or the workflow file's base name when
@@ -161,20 +225,27 @@ type Workflow struct {
161 225 BlobSHA string
162 226 Version int
163 227 Branches []string
228 // Paths and PathsIgnore filter a push by the files it changes. An empty Paths
229 // means every path; PathsIgnore removes paths from that set. A push with no
230 // changed files matches neither filter.
231 Paths []string
232 PathsIgnore []string
164 233 // Tags are the tag patterns that trigger this ci. A tag run is how a
165 234 // release build happens: the tag names what is being released.
166 235 Tags []string
167 Jobs []Job
236 // ChangeRequest, Issues and Comments are metadata triggers. They are nil when
237 // the workflow does not listen to that event.
238 ChangeRequest *EventTrigger
239 Issues *EventTrigger
240 Comments *EventTrigger
241 Schedules []Schedule
242 Dispatch *Dispatch
243 Jobs []Job
168 244 }
169 245
170 246 // AppliesToTag reports whether this workflow should run for a tag push.
171 247 func (w *Workflow) AppliesToTag(tag string) bool {
172 for _, pattern := range w.Tags {
173 if ok, err := path.Match(pattern, tag); err == nil && ok {
174 return true
175 }
176 }
177 return false
248 return matchRefPatterns(w.Tags, tag, false)
178 249 }
179 250
180 251 // Applies reports whether this workflow should run for a push to branch.
@@ -183,12 +254,131 @@ func (w *Workflow) AppliesToTag(tag string) bool {
183 254 // as one path segment, which silently excluded contributor branches such as
184 255 // contrib/<account-name>/fix. Other patterns keep path.Match's one-segment rule, so
185 256 // "release/*" does not accidentally become "release/anything/at/all".
257 //
258 // A leading ! excludes what an earlier pattern included. The last matching pattern
259 // wins, so ["*", "!main"] runs everywhere except main.
186 260 func (w *Workflow) Applies(branch string) bool {
187 for _, pattern := range w.Branches {
188 if pattern == "*" {
261 return matchRefPatterns(w.Branches, branch, true)
262 }
263
264 // AppliesToPush reports whether this workflow should run for a branch or tag push
265 // with the given changed paths. Exactly one of branch and tag is expected.
266 func (w *Workflow) AppliesToPush(branch, tag string, changed []string) bool {
267 switch {
268 case branch != "" && tag == "":
269 if !w.Applies(branch) {
270 return false
271 }
272 case tag != "" && branch == "":
273 if !w.AppliesToTag(tag) {
274 return false
275 }
276 default:
277 return false
278 }
279 return w.PathsMatch(changed)
280 }
281
282 // AppliesToEvent reports whether this workflow listens to one non-push event.
283 func (w *Workflow) AppliesToEvent(kind, action string) bool {
284 switch kind {
285 case "change_request":
286 return w.ChangeRequest != nil && containsString(w.ChangeRequest.Types, action)
287 case "issue":
288 return w.Issues != nil && containsString(w.Issues.Types, action)
289 case "comment":
290 return w.Comments != nil && containsString(w.Comments.Types, action)
291 case "schedule":
292 return len(w.Schedules) > 0
293 case "workflow_dispatch":
294 return w.Dispatch != nil
295 }
296 return false
297 }
298
299 // PathsMatch applies the push path filters to the paths a push changed. An ignored
300 // path is removed before the allow list is consulted, so an ignored file cannot
301 // make a workflow run; when every changed path is ignored, nothing runs. A push
302 // with no changed files matches only a workflow with no path filters.
303 func (w *Workflow) PathsMatch(changed []string) bool {
304 if len(w.Paths) == 0 && len(w.PathsIgnore) == 0 {
305 return true
306 }
307 for _, changedPath := range changed {
308 if matchAnyPath(w.PathsIgnore, changedPath) {
309 continue
310 }
311 if len(w.Paths) == 0 || matchAnyPath(w.Paths, changedPath) {
312 return true
313 }
314 }
315 return false
316 }
317
318 func matchRefPatterns(patterns []string, name string, starMatchesAll bool) bool {
319 matched := false
320 for _, raw := range patterns {
321 pattern := strings.TrimSpace(raw)
322 negated := strings.HasPrefix(pattern, "!")
323 if negated {
324 pattern = strings.TrimPrefix(pattern, "!")
325 }
326 if pattern == "" {
327 continue
328 }
329 if starMatchesAll && pattern == "*" {
330 matched = !negated
331 continue
332 }
333 if ok, err := path.Match(pattern, name); err == nil && ok {
334 matched = !negated
335 }
336 }
337 return matched
338 }
339
340 func matchAnyPath(patterns []string, name string) bool {
341 for _, pattern := range patterns {
342 if matchPathPattern(pattern, name) {
189 343 return true
190 344 }
191 if ok, err := path.Match(pattern, branch); err == nil && ok {
345 }
346 return false
347 }
348
349 // matchPathPattern matches one path pattern. A single * stays inside one segment;
350 // ** spans zero or more segments, so docs/** covers the directory itself and every
351 // file under it.
352 func matchPathPattern(pattern, name string) bool {
353 return matchPathSegments(strings.Split(pattern, "/"), strings.Split(name, "/"))
354 }
355
356 func matchPathSegments(pattern, name []string) bool {
357 for len(pattern) > 0 {
358 if pattern[0] == "**" {
359 if matchPathSegments(pattern[1:], name) {
360 return true
361 }
362 if len(name) == 0 {
363 return false
364 }
365 name = name[1:]
366 continue
367 }
368 if len(name) == 0 {
369 return false
370 }
371 if ok, err := path.Match(pattern[0], name[0]); err != nil || !ok {
372 return false
373 }
374 pattern, name = pattern[1:], name[1:]
375 }
376 return len(name) == 0
377 }
378
379 func containsString(list []string, want string) bool {
380 for _, value := range list {
381 if value == want {
192 382 return true
193 383 }
194 384 }
@@ -210,6 +400,128 @@ type rawPages struct {
210 400 Path string `yaml:"path"`
211 401 }
212 402
403 // rawEventTrigger accepts `change-request:`, `issue:` and `comment:` as either an
404 // empty value (all the default actions) or a mapping with a types list. It rejects
405 // unknown keys itself because a custom unmarshaler is what makes the two spellings
406 // possible, and the outer decoder's KnownFields cannot see inside the node.
407 type rawEventTrigger struct {
408 Set bool
409 Types []string
410 }
411
412 func (t *rawEventTrigger) UnmarshalYAML(node *yaml.Node) error {
413 t.Set = true
414 if node.Tag == "!!null" {
415 return nil
416 }
417 if node.Kind != yaml.MappingNode {
418 return fmt.Errorf("expects a mapping like {types: [opened]} or an empty value")
419 }
420 for i := 0; i < len(node.Content); i += 2 {
421 key := node.Content[i].Value
422 if key != "types" {
423 return fmt.Errorf("field %s is not a known key here", key)
424 }
425 if err := node.Content[i+1].Decode(&t.Types); err != nil {
426 return err
427 }
428 }
429 return nil
430 }
431
432 type rawSchedule struct {
433 Cron string `yaml:"cron"`
434 }
435
436 // rawDispatch accepts `workflow-dispatch:` as an empty value or a mapping with
437 // inputs. The same strict-key rule as rawEventTrigger applies.
438 type rawDispatch struct {
439 Set bool
440 Inputs map[string]rawDispatchInput
441 }
442
443 type rawDispatchInput struct {
444 Description string
445 Type string
446 Required bool
447 Default string
448 DefaultSet bool
449 Options []string
450 }
451
452 func (t *rawDispatch) UnmarshalYAML(node *yaml.Node) error {
453 t.Set = true
454 if node.Tag == "!!null" {
455 return nil
456 }
457 if node.Kind != yaml.MappingNode {
458 return fmt.Errorf("expects a mapping like {inputs: {...}} or an empty value")
459 }
460 for i := 0; i < len(node.Content); i += 2 {
461 key := node.Content[i].Value
462 if key != "inputs" {
463 return fmt.Errorf("field %s is not a known key here", key)
464 }
465 value := node.Content[i+1]
466 if value.Tag == "!!null" {
467 continue
468 }
469 if value.Kind != yaml.MappingNode {
470 return fmt.Errorf("inputs expects a mapping of names to input definitions")
471 }
472 t.Inputs = map[string]rawDispatchInput{}
473 for j := 0; j < len(value.Content); j += 2 {
474 name := value.Content[j].Value
475 in, err := decodeDispatchInput(value.Content[j+1])
476 if err != nil {
477 return fmt.Errorf("inputs.%s: %w", name, err)
478 }
479 t.Inputs[name] = in
480 }
481 }
482 return nil
483 }
484
485 func decodeDispatchInput(node *yaml.Node) (rawDispatchInput, error) {
486 var in rawDispatchInput
487 if node.Tag == "!!null" {
488 return in, nil
489 }
490 if node.Kind != yaml.MappingNode {
491 return in, fmt.Errorf("expects a mapping of description, type, required, default and options")
492 }
493 for i := 0; i < len(node.Content); i += 2 {
494 key := node.Content[i].Value
495 value := node.Content[i+1]
496 switch key {
497 case "description":
498 if err := value.Decode(&in.Description); err != nil {
499 return in, err
500 }
501 case "type":
502 if err := value.Decode(&in.Type); err != nil {
503 return in, err
504 }
505 case "required":
506 if err := value.Decode(&in.Required); err != nil {
507 return in, err
508 }
509 case "default":
510 // The value is kept as the text YAML saw, so a quoted "false" and a bare
511 // false both reach the type check as the same string.
512 in.Default = value.Value
513 in.DefaultSet = true
514 case "options":
515 if err := value.Decode(&in.Options); err != nil {
516 return in, err
517 }
518 default:
519 return in, fmt.Errorf("field %s is not a known key here", key)
520 }
521 }
522 return in, nil
523 }
524
213 525 type rawJob struct {
214 526 Name string `yaml:"name"`
215 527 Description string `yaml:"description"`
@@ -254,17 +566,70 @@ type rawWorkflow struct {
254 566 Name string `yaml:"name"`
255 567 Description string `yaml:"description"`
256 568 On struct {
257 Push struct {
258 // Pointers, so "absent" and "empty" stay different: a workflow with tags
259 // and no branches runs only on tags, while one with neither runs on every
260 // branch.
261 Branches *[]string `yaml:"branches"`
262 Tags *[]string `yaml:"tags"`
263 } `yaml:"push"`
569 Push rawPush `yaml:"push"`
570 ChangeRequest yaml.Node `yaml:"change-request"`
571 Issue yaml.Node `yaml:"issue"`
572 Comment yaml.Node `yaml:"comment"`
573 Schedule []rawSchedule `yaml:"schedule"`
574 WorkflowDispatch yaml.Node `yaml:"workflow-dispatch"`
264 575 } `yaml:"on"`
265 576 Jobs map[string]rawJob `yaml:"jobs"`
266 577 }
267 578
579 // rawPush records that the push key was present. A workflow with only event
580 // triggers must not acquire the ordinary every-branch push default merely because
581 // it said nothing about branches.
582 type rawPush struct {
583 Set bool
584 Branches *[]string
585 Tags *[]string
586 Paths *[]string
587 PathsIgnore *[]string
588 }
589
590 func (p *rawPush) UnmarshalYAML(node *yaml.Node) error {
591 p.Set = true
592 if node.Tag == "!!null" {
593 return nil
594 }
595 if node.Kind != yaml.MappingNode {
596 return fmt.Errorf("push expects a mapping")
597 }
598 for i := 0; i < len(node.Content); i += 2 {
599 key := node.Content[i].Value
600 value := node.Content[i+1]
601 switch key {
602 case "branches":
603 var v []string
604 if err := value.Decode(&v); err != nil {
605 return err
606 }
607 p.Branches = &v
608 case "tags":
609 var v []string
610 if err := value.Decode(&v); err != nil {
611 return err
612 }
613 p.Tags = &v
614 case "paths":
615 var v []string
616 if err := value.Decode(&v); err != nil {
617 return err
618 }
619 p.Paths = &v
620 case "paths-ignore":
621 var v []string
622 if err := value.Decode(&v); err != nil {
623 return err
624 }
625 p.PathsIgnore = &v
626 default:
627 return fmt.Errorf("field %s is not a known key here", key)
628 }
629 }
630 return nil
631 }
632
268 633 // Parse validates one workflow file. Every problem is reported, not just the first.
269 634 func Parse(file string, content string) (*Workflow, []string) {
270 635 var problems []string
@@ -310,29 +675,89 @@ func Parse(file string, content string) (*Workflow, []string) {
310 675 } else {
311 676 wf.Description = desc
312 677 }
313 switch {
314 case rw.On.Push.Branches != nil:
315 wf.Branches = *rw.On.Push.Branches
316 case rw.On.Push.Tags == nil:
317 // Nothing was said, so this workflow is an ordinary push ci. Saying
318 // "tags:" is saying what it triggers on, so it does not also mean branches.
319 wf.Branches = []string{"*"}
320 }
321 for _, b := range wf.Branches {
322 if strings.TrimSpace(b) == "" || !branchGlob.MatchString(b) || strings.Contains(b, "..") {
323 bad("on.push.branches: %q is not a branch pattern", b)
324 }
325 }
326 678 // Tags have no default: a workflow that runs on a tag push has to say so, or a
327 679 // release build would fire on every push.
328 680 if rw.On.Push.Tags != nil {
329 681 for _, tg := range *rw.On.Push.Tags {
330 if strings.TrimSpace(tg) == "" || !branchGlob.MatchString(tg) || strings.Contains(tg, "..") {
682 pattern := strings.TrimPrefix(strings.TrimSpace(tg), "!")
683 if pattern == "" || !branchGlob.MatchString(tg) || strings.Contains(tg, "..") {
331 684 bad("on.push.tags: %q is not a tag pattern", tg)
332 685 }
333 686 wf.Tags = append(wf.Tags, tg)
334 687 }
335 688 }
689 if rw.On.Push.Paths != nil {
690 wf.Paths = validPaths(*rw.On.Push.Paths, "on.push.paths", bad)
691 }
692 if rw.On.Push.PathsIgnore != nil {
693 wf.PathsIgnore = validPaths(*rw.On.Push.PathsIgnore, "on.push.paths-ignore", bad)
694 }
695 if trigger, problem := parseEventNode(&rw.On.ChangeRequest, "on.change-request",
696 changeRequestActions, defaultChangeRequestActions); problem != "" {
697 bad("%s", problem)
698 } else {
699 wf.ChangeRequest = trigger
700 }
701 if trigger, problem := parseEventNode(&rw.On.Issue, "on.issue",
702 issueActions, defaultIssueActions); problem != "" {
703 bad("%s", problem)
704 } else {
705 wf.Issues = trigger
706 }
707 if trigger, problem := parseEventNode(&rw.On.Comment, "on.comment",
708 commentActions, defaultCommentActions); problem != "" {
709 bad("%s", problem)
710 } else {
711 wf.Comments = trigger
712 }
713 if len(rw.On.Schedule) > MaxSchedules {
714 bad("on.schedule: %d entries, limit %d", len(rw.On.Schedule), MaxSchedules)
715 }
716 for i, rs := range rw.On.Schedule {
717 cron := strings.TrimSpace(rs.Cron)
718 if cron == "" {
719 bad("on.schedule[%d]: cron is required", i)
720 continue
721 }
722 if _, err := ParseCron(cron); err != nil {
723 bad("on.schedule[%d].cron: %v", i, err)
724 continue
725 }
726 wf.Schedules = append(wf.Schedules, Schedule{Cron: cron})
727 }
728 if rw.On.WorkflowDispatch.Kind != 0 {
729 var raw rawDispatch
730 if err := raw.UnmarshalYAML(&rw.On.WorkflowDispatch); err != nil {
731 bad("on.workflow-dispatch: %v", err)
732 } else if dispatch, problems := parseDispatch(raw); len(problems) > 0 {
733 for _, problem := range problems {
734 bad("%s", problem)
735 }
736 } else {
737 wf.Dispatch = dispatch
738 }
739 }
740
741 switch {
742 case rw.On.Push.Branches != nil:
743 wf.Branches = *rw.On.Push.Branches
744 case rw.On.Push.Tags != nil:
745 // Tags have no branch default: a workflow that runs on a tag says so.
746 case rw.On.Push.Set:
747 // An empty push trigger means every branch, as it always has.
748 wf.Branches = []string{"*"}
749 case wf.ChangeRequest == nil && wf.Issues == nil && wf.Comments == nil &&
750 len(wf.Schedules) == 0 && wf.Dispatch == nil:
751 // No trigger at all was declared, so this is an ordinary push ci.
752 wf.Branches = []string{"*"}
753 }
754
755 for _, b := range wf.Branches {
756 pattern := strings.TrimPrefix(strings.TrimSpace(b), "!")
757 if pattern == "" || !branchGlob.MatchString(b) || strings.Contains(b, "..") {
758 bad("on.push.branches: %q is not a branch pattern", b)
759 }
760 }
336 761
337 762 if len(rw.Jobs) == 0 {
338 763 bad("at least one job is required")
@@ -628,6 +1053,341 @@ func CheckWithImages(files, images map[string]string) ([]*Workflow, []string) {
628 1053 return out, problems
629 1054 }
630 1055
1056 // validPaths trims and validates a path-filter list. Path filters cannot climb out
1057 // of the tree, and they cannot be empty: a pattern that matches nothing would leave
1058 // a workflow permanently silent.
1059 func validPaths(list []string, where string, bad func(string, ...any)) []string {
1060 if len(list) > MaxPaths {
1061 bad("%s: %d patterns, limit %d", where, len(list), MaxPaths)
1062 }
1063 out := make([]string, 0, len(list))
1064 for _, raw := range list {
1065 p := strings.TrimSpace(raw)
1066 switch {
1067 case p == "":
1068 bad("%s: an empty pattern matches no path", where)
1069 continue
1070 case strings.HasPrefix(p, "/"):
1071 bad("%s: %q must be relative to the repository root", where, p)
1072 continue
1073 case strings.Contains(p, ".."):
1074 bad("%s: %q must not contain ..", where, p)
1075 continue
1076 case !pathGlob.MatchString(p):
1077 bad("%s: %q is not a path pattern", where, p)
1078 continue
1079 }
1080 out = append(out, p)
1081 }
1082 return out
1083 }
1084
1085 // parseEventNode handles an event key written as an empty value (`issue:`) or as a
1086 // mapping. The outer decoder skips a null into a struct field, so the key is read
1087 // as a yaml.Node and absence (Kind 0) stays different from "present and empty".
1088 func parseEventNode(node *yaml.Node, where string, allowed, defaults []string) (*EventTrigger, string) {
1089 if node.Kind == 0 {
1090 return nil, ""
1091 }
1092 var raw rawEventTrigger
1093 if err := raw.UnmarshalYAML(node); err != nil {
1094 return nil, fmt.Sprintf("%s: %v", where, err)
1095 }
1096 return parseEventTrigger(raw, where, allowed, defaults)
1097 }
1098
1099 func parseEventTrigger(raw rawEventTrigger, where string, allowed, defaults []string) (*EventTrigger, string) {
1100 if !raw.Set {
1101 return nil, ""
1102 }
1103 types := raw.Types
1104 if len(types) == 0 {
1105 types = defaults
1106 }
1107 seen := map[string]bool{}
1108 out := make([]string, 0, len(types))
1109 for _, value := range types {
1110 action := strings.TrimSpace(value)
1111 if !containsString(allowed, action) {
1112 return nil, fmt.Sprintf("%s.types: %q is not an action (%s)", where, value, strings.Join(allowed, ", "))
1113 }
1114 if seen[action] {
1115 continue
1116 }
1117 seen[action] = true
1118 out = append(out, action)
1119 }
1120 return &EventTrigger{Types: out}, ""
1121 }
1122
1123 func parseDispatch(raw rawDispatch) (*Dispatch, []string) {
1124 var problems []string
1125 dispatch := &Dispatch{Inputs: map[string]DispatchInput{}}
1126 if len(raw.Inputs) > MaxDispatchInputs {
1127 problems = append(problems, fmt.Sprintf("on.workflow-dispatch.inputs: %d inputs, limit %d",
1128 len(raw.Inputs), MaxDispatchInputs))
1129 }
1130 names := make([]string, 0, len(raw.Inputs))
1131 for name := range raw.Inputs {
1132 names = append(names, name)
1133 }
1134 sort.Strings(names)
1135 for _, name := range names {
1136 where := "on.workflow-dispatch.inputs." + name
1137 rawInput := raw.Inputs[name]
1138 if !dispatchInputRE.MatchString(name) {
1139 problems = append(problems, fmt.Sprintf("%s: names are 1-32 characters, letters, digits, _ and -, starting with a letter or _", where))
1140 continue
1141 }
1142 inputType := strings.TrimSpace(rawInput.Type)
1143 if inputType == "" {
1144 inputType = "string"
1145 }
1146 switch inputType {
1147 case "string", "boolean", "number", "choice":
1148 default:
1149 problems = append(problems, fmt.Sprintf("%s.type: %q is not a type (string, boolean, number, choice)", where, inputType))
1150 continue
1151 }
1152 description := strings.TrimSpace(rawInput.Description)
1153 if len(description) > MaxDescriptionBytes {
1154 problems = append(problems, fmt.Sprintf("%s.description: %d bytes, limit %d", where, len(description), MaxDescriptionBytes))
1155 }
1156 if strings.ContainsFunc(description, func(r rune) bool {
1157 return r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029'
1158 }) {
1159 problems = append(problems, fmt.Sprintf("%s.description: must be one line of text", where))
1160 }
1161 defaultValue := strings.TrimSpace(rawInput.Default)
1162 if rawInput.DefaultSet && len(defaultValue) > MaxDispatchValueBytes {
1163 problems = append(problems, fmt.Sprintf("%s.default: %d bytes, limit %d", where, len(defaultValue), MaxDispatchValueBytes))
1164 }
1165 options := make([]string, 0, len(rawInput.Options))
1166 seenOptions := map[string]bool{}
1167 for _, option := range rawInput.Options {
1168 option = strings.TrimSpace(option)
1169 if option == "" {
1170 problems = append(problems, fmt.Sprintf("%s.options: an empty choice is not a value", where))
1171 continue
1172 }
1173 if seenOptions[option] {
1174 continue
1175 }
1176 seenOptions[option] = true
1177 options = append(options, option)
1178 }
1179 if inputType == "choice" {
1180 if len(options) == 0 {
1181 problems = append(problems, fmt.Sprintf("%s.options: a choice needs at least one option", where))
1182 }
1183 if rawInput.DefaultSet && !seenOptions[defaultValue] {
1184 problems = append(problems, fmt.Sprintf("%s.default: %q is not one of the options", where, defaultValue))
1185 }
1186 } else if len(options) > 0 {
1187 problems = append(problems, fmt.Sprintf("%s.options: only a choice input takes options", where))
1188 }
1189 if rawInput.DefaultSet {
1190 if problem := validDispatchValue(inputType, defaultValue, options); problem != "" {
1191 problems = append(problems, fmt.Sprintf("%s.default: %s", where, problem))
1192 }
1193 }
1194 dispatch.Inputs[name] = DispatchInput{
1195 Description: description,
1196 Type: inputType,
1197 Required: rawInput.Required,
1198 Default: defaultValue,
1199 DefaultSet: rawInput.DefaultSet,
1200 Options: options,
1201 }
1202 }
1203 if len(problems) > 0 {
1204 return nil, problems
1205 }
1206 return dispatch, nil
1207 }
1208
1209 // ResolveDispatchInputs applies the declared types, defaults and required flags to
1210 // a manual run's values. It returns the values a step should see; an optional input
1211 // with no value is absent rather than an empty string, so a step can tell "not set"
1212 // from "set to empty".
1213 func (w *Workflow) ResolveDispatchInputs(given map[string]string) (map[string]string, []string) {
1214 if w.Dispatch == nil {
1215 return nil, []string{"this workflow has no workflow-dispatch trigger"}
1216 }
1217 var problems []string
1218 for name := range given {
1219 if _, ok := w.Dispatch.Inputs[name]; !ok {
1220 problems = append(problems, fmt.Sprintf("%q is not a declared input", name))
1221 }
1222 }
1223 names := make([]string, 0, len(w.Dispatch.Inputs))
1224 for name := range w.Dispatch.Inputs {
1225 names = append(names, name)
1226 }
1227 sort.Strings(names)
1228 values := map[string]string{}
1229 for _, name := range names {
1230 decl := w.Dispatch.Inputs[name]
1231 value, givenValue := given[name]
1232 if !givenValue {
1233 if !decl.DefaultSet {
1234 if decl.Required {
1235 problems = append(problems, fmt.Sprintf("%s is required", name))
1236 }
1237 continue
1238 }
1239 value = decl.Default
1240 }
1241 if len(value) > MaxDispatchValueBytes {
1242 problems = append(problems, fmt.Sprintf("%s: %d bytes, limit %d", name, len(value), MaxDispatchValueBytes))
1243 continue
1244 }
1245 if strings.ContainsAny(value, "\r\n\x00") {
1246 problems = append(problems, fmt.Sprintf("%s: must be one line", name))
1247 continue
1248 }
1249 if problem := validDispatchValue(decl.Type, value, decl.Options); problem != "" {
1250 problems = append(problems, fmt.Sprintf("%s: %s", name, problem))
1251 continue
1252 }
1253 values[name] = value
1254 }
1255 if len(problems) > 0 {
1256 return nil, problems
1257 }
1258 return values, nil
1259 }
1260
1261 func validDispatchValue(kind, value string, options []string) string {
1262 switch kind {
1263 case "boolean":
1264 if _, err := strconv.ParseBool(value); err != nil {
1265 return fmt.Sprintf("%q is not true or false", value)
1266 }
1267 case "number":
1268 if _, err := strconv.ParseFloat(value, 64); err != nil {
1269 return fmt.Sprintf("%q is not a number", value)
1270 }
1271 case "choice":
1272 if !containsString(options, value) {
1273 return fmt.Sprintf("%q is not one of %s", value, strings.Join(options, ", "))
1274 }
1275 }
1276 return ""
1277 }
1278
1279 // Cron is one parsed five-field schedule. The fields are minute, hour, day of
1280 // month, month, and day of week, in UTC. It supports *, lists, ranges, and a step
1281 // on * or a range. Names and @shorthands are not part of the format.
1282 type Cron struct {
1283 minute, hour, dayOfMonth, month, dayOfWeek uint64
1284 dayOfMonthAny, dayOfWeekAny bool
1285 }
1286
1287 // ParseCron parses one schedule expression.
1288 func ParseCron(spec string) (*Cron, error) {
1289 fields := strings.Fields(spec)
1290 if len(fields) != 5 {
1291 return nil, fmt.Errorf("%q needs 5 fields (minute hour day month weekday)", spec)
1292 }
1293 c := &Cron{}
1294 var err error
1295 if c.minute, _, err = parseCronField(fields[0], 0, 59, false); err != nil {
1296 return nil, fmt.Errorf("minute: %w", err)
1297 }
1298 if c.hour, _, err = parseCronField(fields[1], 0, 23, false); err != nil {
1299 return nil, fmt.Errorf("hour: %w", err)
1300 }
1301 if c.dayOfMonth, c.dayOfMonthAny, err = parseCronField(fields[2], 1, 31, false); err != nil {
1302 return nil, fmt.Errorf("day of month: %w", err)
1303 }
1304 if c.month, _, err = parseCronField(fields[3], 1, 12, false); err != nil {
1305 return nil, fmt.Errorf("month: %w", err)
1306 }
1307 if c.dayOfWeek, c.dayOfWeekAny, err = parseCronField(fields[4], 0, 7, true); err != nil {
1308 return nil, fmt.Errorf("day of week: %w", err)
1309 }
1310 // Sunday is both 0 and 7. Normalising here means a schedule that says 7 and
1311 // one that says 0 cannot disagree about the same day.
1312 if c.dayOfWeek&(1<<7) != 0 {
1313 c.dayOfWeek |= 1
1314 c.dayOfWeek &^= 1 << 7
1315 }
1316 return c, nil
1317 }
1318
1319 // Matches reports whether t falls in this schedule's minute, hour, and date.
1320 func (c *Cron) Matches(t time.Time) bool {
1321 if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 ||
1322 c.month&(1<<uint(int(t.Month()))) == 0 {
1323 return false
1324 }
1325 dayOfMonth := c.dayOfMonth&(1<<uint(t.Day())) != 0
1326 dayOfWeek := c.dayOfWeek&(1<<uint(int(t.Weekday()))) != 0
1327 // Cron's odd rule: when both date fields are restricted, either one may match.
1328 if c.dayOfMonthAny || c.dayOfWeekAny {
1329 return dayOfMonth && dayOfWeek
1330 }
1331 return dayOfMonth || dayOfWeek
1332 }
1333
1334 func parseCronField(field string, min, max int, allowSeven bool) (uint64, bool, error) {
1335 var mask uint64
1336 any := false
1337 for _, part := range strings.Split(field, ",") {
1338 part = strings.TrimSpace(part)
1339 if part == "" {
1340 return 0, false, fmt.Errorf("empty list entry")
1341 }
1342 original := part
1343 step := 1
1344 if i := strings.IndexByte(part, '/'); i >= 0 {
1345 n, err := strconv.Atoi(part[i+1:])
1346 if err != nil || n <= 0 {
1347 return 0, false, fmt.Errorf("%q has an invalid step", original)
1348 }
1349 step = n
1350 part = part[:i]
1351 }
1352 start, end := min, max
1353 switch {
1354 case part == "*":
1355 if step == 1 {
1356 any = true
1357 }
1358 case strings.Contains(part, "-"):
1359 bounds := strings.SplitN(part, "-", 2)
1360 var err error
1361 if start, err = strconv.Atoi(bounds[0]); err != nil {
1362 return 0, false, fmt.Errorf("%q is not a range", original)
1363 }
1364 if end, err = strconv.Atoi(bounds[1]); err != nil {
1365 return 0, false, fmt.Errorf("%q is not a range", original)
1366 }
1367 default:
1368 if step != 1 {
1369 return 0, false, fmt.Errorf("%q needs a range or * before /%d", original, step)
1370 }
1371 n, err := strconv.Atoi(part)
1372 if err != nil {
1373 return 0, false, fmt.Errorf("%q is not a number", original)
1374 }
1375 start, end = n, n
1376 }
1377 if start < min || end > max || start > end {
1378 return 0, false, fmt.Errorf("%q is outside %d-%d", original, min, max)
1379 }
1380 for n := start; n <= end; n += step {
1381 if allowSeven && n == 7 {
1382 mask |= 1 << 7
1383 continue
1384 }
1385 mask |= 1 << uint(n)
1386 }
1387 }
1388 return mask, any, nil
1389 }
1390
631 1391 // tidyYAMLError drops the Go type name yaml.v3 leaks into its messages, which
632 1392 // means nothing to someone editing a ci.
633 1393 func tidyYAMLError(err error) string {