1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6 "bnema.dev/ermite-actions/template"
7 "strings"
8)
9
10// On is the "on" key: the events that trigger a workflow.
11type On struct {
12 // Events are the events in file order.
13 Events []*Event
14}
15
16// Event returns the configuration of the named event, or nil.
17func (o *On) Event(name string) *Event {
18 if o == nil {
19 return nil
20 }
21 for _, e := range o.Events {
22 if e.Name == name {
23 return e
24 }
25 }
26 return nil
27}
28
29// Has reports whether the workflow is triggered by the named event.
30func (o *On) Has(name string) bool { return o.Event(name) != nil }
31
32// Event is one event of the "on" key with its filters. A filter that is
33// absent from the file is nil; one written as an empty list is empty and
34// non-nil.
35type Event struct {
36 Name string
37 // Token is the position of the event name.
38 Token template.Token
39
40 Branches []string
41 BranchesIgnore []string
42 Tags []string
43 TagsIgnore []string
44 Paths []string
45 PathsIgnore []string
46 Types []string
47 Versions []string
48 Names []string
49 Workflows []string
50
51 cf *compiledFilters
52
53 // Schedule holds the entries of the "schedule" event.
54 Schedule []Schedule
55 // Inputs holds the inputs of workflow_dispatch and workflow_call.
56 Inputs []Input
57}
58
59// Schedule is an entry of the "schedule" event.
60type Schedule struct {
61 Cron string
62 Timezone string
63}
64
65// Input is an input of workflow_dispatch or workflow_call.
66type Input struct {
67 Name string
68 Type string // "string", "choice", "boolean", "number", "environment"
69 Description string
70 Required bool
71 // HasDefault reports whether the file gave a default; Default is its text
72 // (an expression keeps its ${{ }} form).
73 HasDefault bool
74 Default string
75 Options []string
76}
77
78// convertOn is a port of convertOn in converter/events.ts.
79func (c *converter) convertOn(t template.Token) *On {
80 on := &On{}
81 switch t := t.(type) {
82 case *template.String:
83 on.Events = append(on.Events, &Event{Name: t.Value, Token: t})
84 case *template.Sequence:
85 for _, it := range t.Items {
86 s := asString(it, "on")
87 on.Events = append(on.Events, &Event{Name: s.Value, Token: s})
88 }
89 case *template.Mapping:
90 for _, p := range t.Pairs {
91 key := asString(p.Key, "event name")
92 ev := &Event{Name: key.Value, Token: key}
93 on.Events = append(on.Events, ev)
94 if p.Value.Kind() == template.KindNull {
95 continue
96 }
97 if key.Value == "schedule" {
98 ev.Schedule = c.convertSchedule(asSeq(p.Value, "event schedule"))
99 continue
100 }
101 m := asMap(p.Value, "event "+key.Value)
102 switch key.Value {
103 case "workflow_call":
104 c.convertWorkflowCall(ev, m)
105 continue
106 case "workflow_dispatch":
107 c.convertWorkflowDispatch(ev, m)
108 continue
109 }
110 ev.Branches = patternFilter("branches", m)
111 ev.BranchesIgnore = patternFilter("branches-ignore", m)
112 ev.Tags = patternFilter("tags", m)
113 ev.TagsIgnore = patternFilter("tags-ignore", m)
114 ev.Paths = patternFilter("paths", m)
115 ev.PathsIgnore = patternFilter("paths-ignore", m)
116 ev.Types = patternFilter("types", m)
117 ev.Versions = patternFilter("versions", m)
118 ev.Names = patternFilter("names", m)
119 ev.Workflows = patternFilter("workflows", m)
120 }
121 default:
122 c.ctx.ErrorAt(t, "Invalid format for 'on'")
123 return &On{}
124 }
125 return on
126}
127
128// patternFilter reads a filter that is a string or a list of strings; nil
129// when the key is absent.
130func patternFilter(name string, m *template.Mapping) []string {
131 var out []string
132 for _, p := range m.Pairs {
133 key := asString(p.Key, name+" filter key")
134 if key.Value != name {
135 continue
136 }
137 if s, ok := p.Value.(*template.String); ok {
138 out = []string{s.Value}
139 continue
140 }
141 out = []string{}
142 for _, it := range asSeq(p.Value, name+" list").Items {
143 out = append(out, asString(it, name+" list item").Value)
144 }
145 }
146 return out
147}
148
149func (c *converter) convertSchedule(seq *template.Sequence) []Schedule {
150 var out []Schedule
151 for _, it := range seq.Items {
152 m := asMap(it, "event schedule")
153 var cfg Schedule
154 valid := true
155 for _, e := range m.Pairs {
156 key := asString(e.Key, "schedule key")
157 switch key.Value {
158 case "cron":
159 cron := asString(e.Value, "schedule cron")
160 if !isValidCron(cron.Value) {
161 c.ctx.ErrorAt(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)")
162 }
163 cfg.Cron = cron.Value
164 case "timezone":
165 cfg.Timezone = asString(e.Value, "schedule timezone").Value
166 default:
167 c.ctx.ErrorAt(key, "Invalid schedule key")
168 valid = false
169 }
170 }
171 switch {
172 case valid && cfg.Cron != "":
173 out = append(out, cfg)
174 case valid:
175 c.ctx.ErrorAt(m, "Missing required key 'cron' in schedule entry")
176 }
177 }
178 return out
179}
180
181func (c *converter) convertWorkflowDispatch(ev *Event, m *template.Mapping) {
182 for _, p := range m.Pairs {
183 if asString(p.Key, "workflow dispatch input key").Value == "inputs" {
184 ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), false)
185 }
186 }
187}
188
189func (c *converter) convertWorkflowCall(ev *Event, m *template.Mapping) {
190 for _, p := range m.Pairs {
191 switch asString(p.Key, "workflow dispatch input key").Value {
192 case "inputs":
193 ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), true)
194 case "secrets":
195 for _, s := range asMap(p.Value, "workflow dispatch inputs").Pairs {
196 asString(s.Key, "secret name")
197 if sm, ok := s.Value.(*template.Mapping); ok {
198 for _, kv := range sm.Pairs {
199 switch asString(kv.Key, "workflow call secret key").Value {
200 case "description":
201 asString(kv.Value, "secret description")
202 case "required":
203 asBool(kv.Value, "secret required")
204 }
205 }
206 }
207 }
208 }
209 }
210}
211
212// convertInputs ports convertWorkflowDispatchInputs and convertWorkflowInputs.
213// The two differ only in that call inputs skip the default check when the
214// default is an expression.
215func (c *converter) convertInputs(m *template.Mapping, call bool) []Input {
216 var out []Input
217 for _, p := range m.Pairs {
218 name := asString(p.Key, "input name")
219 im := asMap(p.Value, "input configuration")
220 out = append(out, c.convertInput(name.Value, im, call))
221 }
222 return out
223}
224
225func (c *converter) convertInput(name string, m *template.Mapping, call bool) Input {
226 in := Input{Name: name, Type: "string"}
227 var def template.Token
228 var options []string
229 hasOptions := false
230 for _, p := range m.Pairs {
231 key := asString(p.Key, "workflow dispatch input key")
232 switch key.Value {
233 case "description":
234 in.Description = asString(p.Value, "input description").Value
235 case "required":
236 in.Required = asBool(p.Value, "input required").Value
237 case "default":
238 def = asScalar(p.Value, "input default")
239 case "type":
240 in.Type = asString(p.Value, "input type").Value
241 case "options":
242 hasOptions = true
243 options = []string{}
244 for _, it := range asSeq(p.Value, "input options").Items {
245 options = append(options, asString(it, "input options item").Value)
246 }
247 in.Options = options
248 default:
249 c.ctx.Errorf(p.Key, "Invalid key '%s'", key.Value)
250 }
251 }
252 if def != nil {
253 in.HasDefault = true
254 in.Default = def.String()
255 if !call || !template.IsExpression(def) {
256 c.handle(def, func() {
257 switch in.Type {
258 case "boolean":
259 asBool(def, "input default")
260 case "string", "choice", "environment":
261 asString(def, "input default")
262 }
263 })
264 }
265 }
266 if in.Type == "choice" {
267 if !hasOptions || len(options) == 0 {
268 c.ctx.ErrorAt(m, "Missing 'options' for choice input")
269 }
270 } else if hasOptions {
271 c.ctx.ErrorAt(m, "Input type is not 'choice', but 'options' is defined")
272 }
273 return in
274}
275
276// filterNames are the pattern filters of an event, in the order they are
277// checked and reported.
278var filterNames = []string{"branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore"}
279
280// filterLists returns the lists of the six pattern filters by name.
281func (e *Event) filterLists() map[string][]string {
282 return map[string][]string{
283 "branches": e.Branches, "branches-ignore": e.BranchesIgnore,
284 "tags": e.Tags, "tags-ignore": e.TagsIgnore,
285 "paths": e.Paths, "paths-ignore": e.PathsIgnore,
286 }
287}
288
289// compiledFilters holds the compiled pattern lists of an event.
290type compiledFilters struct {
291 branches, branchesIgnore, tags, tagsIgnore, paths, pathsIgnore []*Pattern
292}
293
294func (e *Event) compileFilters() (*compiledFilters, map[string]error) {
295 errs := map[string]error{}
296 var cf compiledFilters
297 dst := map[string]*[]*Pattern{
298 "branches": &cf.branches, "branches-ignore": &cf.branchesIgnore,
299 "tags": &cf.tags, "tags-ignore": &cf.tagsIgnore,
300 "paths": &cf.paths, "paths-ignore": &cf.pathsIgnore,
301 }
302 for name, list := range e.filterLists() {
303 l, err := compileList(list)
304 if err != nil {
305 errs[name] = err
306 }
307 *dst[name] = l
308 }
309 return &cf, errs
310}
311
312// filters returns the compiled filters: those made by Parse, or, for an
313// Event built by hand, a fresh compilation (a pattern that does not compile
314// never matches).
315func (e *Event) filters() *compiledFilters {
316 if e.cf != nil {
317 return e.cf
318 }
319 cf, _ := e.compileFilters()
320 return cf
321}
322
323// validateFilters checks the pattern filters of every event, as GitHub does
324// and @actions/workflow-parser does not:
325//
326// - a filter and its "-ignore" counterpart (branches and branches-ignore,
327// tags and tags-ignore, paths and paths-ignore) cannot be used together;
328// - a list made only of "!" patterns is an error: such a list can match
329// nothing, and GitHub requires at least one pattern without "!";
330// - every pattern must compile.
331//
332// The messages are ours. The compiled patterns are kept in the events.
333//
334// TODO(verify with recording): the text and position of GitHub's errors,
335// whether they apply to every event that has these filters, and whether a
336// "!" pattern is refused in the "-ignore" lists too.
337func (c *converter) validateFilters() {
338 if c.w.On == nil {
339 return
340 }
341 for _, ev := range c.w.On.Events {
342 lists := ev.filterLists()
343 for _, pr := range [][2]string{{"branches", "branches-ignore"}, {"tags", "tags-ignore"}, {"paths", "paths-ignore"}} {
344 if lists[pr[0]] != nil && lists[pr[1]] != nil {
345 c.ctx.Errorf(ev.Token, "Event '%s' cannot use both '%s' and '%s'. Use only one of them.", ev.Name, pr[0], pr[1])
346 }
347 }
348 cf, errs := ev.compileFilters()
349 for _, name := range filterNames {
350 list := lists[name]
351 if err := errs[name]; err != nil {
352 c.ctx.Errorf(ev.Token, "Event '%s' filter '%s': %v", ev.Name, name, err)
353 }
354 if onlyNegated(list) {
355 c.ctx.Errorf(ev.Token, "Event '%s' filter '%s' has only patterns that start with '!'. Add at least one pattern without '!'.", ev.Name, name)
356 }
357 }
358 ev.cf = cf
359 }
360}
361
362// onlyNegated reports whether a non-empty list has no positive pattern.
363func onlyNegated(patterns []string) bool {
364 if len(patterns) == 0 {
365 return false
366 }
367 for _, p := range patterns {
368 if !strings.HasPrefix(p, "!") {
369 return false
370 }
371 }
372 return true
373}