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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 29m ago 32290114
history blame raw 69.6 KiB
1// Package ci validates Vole's CI workflow files and defines the CI schema.
2//
3// .vole/workflows/<name>.yaml
4//
5// The format is deliberately small: jobs of shell steps, filtered by branch. It
6// is validated when it is pushed, so a broken workflow fails the push that
7// introduced it instead of silently never running — which is the failure mode
8// that wastes the most of a person's afternoon.
9package ci
10
11import (
12 "fmt"
13 "io/fs"
14 "os"
15 "path"
16 "path/filepath"
17 "regexp"
18 "sort"
19 "strconv"
20 "strings"
21 "time"
22
23 "gopkg.in/yaml.v3"
24 "rodent.software/vole/core/git"
25 "rodent.software/vole/core/role"
26)
27
28const (
29 // Dir is where workflows live, relative to the repository root.
30 Dir = ".vole/workflows"
31
32 MaxFileBytes = 256 << 10
33 MaxStepBytes = 64 << 10
34 MaxJobs = 50
35 MaxSteps = 100
36 MaxArtifacts = 32
37 // MaxServices bounds the containers a job asks for beside its own. A service is
38 // a process on the runner, not a declaration, and the runner's resource flags
39 // apply to each one; a job that needs more than a handful is describing an
40 // environment that belongs in its image.
41 MaxServices = 3
42 // MaxLabels bounds runs-on. A job naming more than a handful of labels is
43 // describing a specific machine, which is what --repos and the allowlist are for.
44 MaxLabels = 8
45 // MaxEnv and MaxEnvValue bound a job's environment, matching what an image
46 // definition may set. An environment is configuration, not a payload.
47 MaxEnv = 64
48 MaxEnvValue = 8 << 10
49 // MaxArtifactPathBytes bounds one pattern. A declaration is a path, not a
50 // manifest: the files it matches are discovered when the job runs.
51 MaxArtifactPathBytes = 256
52 // MaxNameBytes bounds a display name: a workflow, a job. It is a label on a
53 // page, not a sentence.
54 MaxNameBytes = 80
55 // MaxDescriptionBytes bounds a display description. One sentence a reader can
56 // repeat is enough to say what a workflow is for.
57 MaxDescriptionBytes = 500
58 // MaxPaths bounds the path filters a push trigger may declare. A workflow that
59 // needs more than a handful of patterns is describing a directory and should say
60 // so with a `**` pattern.
61 MaxPaths = 20
62 // MaxSchedules bounds the cron entries on one workflow. A schedule is an
63 // expectation the runner pool has to meet, not a place to keep a list.
64 MaxSchedules = 5
65 // MaxDispatchInputs bounds the inputs a manual run may declare.
66 MaxDispatchInputs = 10
67 // MaxDispatchValueBytes bounds one manual input value.
68 MaxDispatchValueBytes = 4 << 10
69 // MaxFilterAccounts bounds one event filter. A filter naming more accounts than
70 // this is describing a roster, which is not what an event trigger is for.
71 MaxFilterAccounts = 10
72 // NetworkNone and NetworkBridge are the isolation a job may ask for with
73 // `network:`. There are two because the container has one switch: a job has the
74 // network or it does not. "host" is not a third value, because a job that shares
75 // the runner's network is not a container any more.
76 NetworkNone = "none"
77 NetworkBridge = "bridge"
78 // MaxEventPayloadBytes bounds the event JSON pinned with a run. It is read as
79 // data from a mounted file, never interpolated into a step.
80 MaxEventPayloadBytes = 64 << 10
81 // DefaultTimeout applies to a step that does not set one and whose job has no
82 // budget of its own. A hung step must not hold a runner forever.
83 DefaultTimeout = 10 * time.Minute
84 // MaxTimeout bounds what a step may ask for and what a job may budget. A step
85 // that declares nothing inside a job with a budget inherits it, so this is also
86 // the longest a single step can effectively run.
87 MaxTimeout = 4 * time.Hour
88 // Version is the only schema this binary understands.
89 Version = 1
90)
91
92var (
93 jobIDRE = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)
94 branchGlob = regexp.MustCompile(`^[A-Za-z0-9*?\[\]!._/-]+$`)
95 // labelRE is a runner label: what a workflow asks for with runs-on and what a
96 // runner says it is with --label. Lowercase because a label is an identity, and
97 // `Linux` and `linux` being different machines is a support question nobody wants.
98 labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
99 // serviceNameRE is a service's name and therefore the hostname a step connects
100 // to. It follows the DNS label rule, because the container runtime publishes it
101 // as a name on the job's network.
102 serviceNameRE = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
103 // accountRE is the roster's account-name rule. A filter names a person, so a value
104 // that could never be an account name is a typo, not a new kind of filter.
105 accountRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
106 // dispatchInputRE is a name that can reach a shell as a variable when a step
107 // chooses to export the inputs it reads from the event payload.
108 dispatchInputRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,31}$`)
109 // secretNameRE is a secret's name and therefore the name of the variable a step
110 // reads it as. Upper case only, so one value has one spelling.
111 secretNameRE = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`)
112 // pathGlob allows the two wildcards a path filter needs and nothing that could
113 // be read as a revision or escape the tree.
114 pathGlob = regexp.MustCompile(`^[A-Za-z0-9*?\[\]!._/-]+$`)
115 // reservedEnv is what the sandbox itself sets. A job that could replace these
116 // could make the container lie about being in CI or move HOME out of the writable
117 // path, so they are refused rather than overridden. The event variables are set
118 // by the runner from the pinned event, never by the workflow.
119 reservedEnv = map[string]bool{
120 "CI": true,
121 "VOLE_CI": true,
122 "VOLE_EVENT": true,
123 "VOLE_EVENT_ACTION": true,
124 "VOLE_EVENT_PATH": true,
125 "GIT_TERMINAL_PROMPT": true,
126 "HOME": true,
127 "TMPDIR": true,
128 // Set by the runner for a job that acts as a bot, and never by a workflow:
129 // they name the run and point git at the credential, and a workflow that
130 // could replace them could redirect the push.
131 "VOLE_RUN_ID": true,
132 "VOLE_REPO": true,
133 "VOLE_SSH_COMMAND": true,
134 "VOLE_KNOWN_HOSTS": true,
135 }
136)
137
138var (
139 changeRequestActions = []string{"opened", "updated", "ready", "closed", "merged"}
140 defaultChangeRequestActions = []string{"opened", "updated", "ready"}
141 issueActions = []string{"opened", "edited", "assigned", "closed", "reopened"}
142 defaultIssueActions = []string{"opened"}
143 commentActions = []string{"created", "edited", "deleted"}
144 defaultCommentActions = []string{"created"}
145)
146
147// ValidLabel reports whether s can be a runner label. It is exported so the runner
148// refuses a bad --label at startup with the same rule the workflow validator applies:
149// a label that can never be stored would otherwise be a machine that silently never
150// matches a job.
151func ValidLabel(s string) error {
152 if !labelRE.MatchString(s) {
153 return fmt.Errorf("%q is not a runner label (1-32 characters: lowercase letters, digits, '.', '_' and '-', starting with a letter or digit)", s)
154 }
155 return nil
156}
157
158// ValidNetwork reports whether s is a network mode a job may ask for. It is
159// exported for the same reason ValidLabel is: the runner checks the pinned job it
160// was handed, and the local run applies the job's request, so all three callers
161// have to agree on what the two values mean.
162func ValidNetwork(s string) error {
163 switch s {
164 case NetworkNone, NetworkBridge:
165 return nil
166 }
167 return fmt.Errorf("%q is not a network mode; use %q or %q", s, NetworkNone, NetworkBridge)
168}
169
170// ValidServiceName reports whether s can name a service. It is exported for the
171// runner, which checks the service names it was handed before turning one into a
172// container name and a network alias: the workflow validator and the runner must
173// agree on what a name is, because the name becomes a hostname.
174func ValidServiceName(s string) error {
175 if !serviceNameRE.MatchString(s) {
176 return fmt.Errorf("%q is not a service name (1-32 characters: lowercase letters, digits and '-', starting with a letter)", s)
177 }
178 return nil
179}
180
181type Step struct {
182 Name string
183 Run string
184 // Timeout is the step's own `timeout:`, or zero when it declares none. Zero is
185 // not "no limit": StepLimit decides what an undeclared step gets, which is the
186 // job's remaining budget or DefaultTimeout.
187 Timeout time.Duration
188}
189
190// StepLimit is how long one step may run, given what is left of its job's budget
191// and the step's own timeout. left is negative when the job declares no budget.
192//
193// A step that declares a timeout gets it, unless the job's budget runs out first. A
194// step that declares nothing gets whatever is left of the budget, so a job that
195// says `timeout: 2h` is enough for a single long step, and falls back to
196// DefaultTimeout when the job has no budget. A result of zero means the budget is
197// spent: the step must not start, and the job has failed.
198func BudgetLeft(deadline, now time.Time) time.Duration {
199 if deadline.IsZero() {
200 return -1
201 }
202 if left := deadline.Sub(now); left > 0 {
203 return left
204 }
205 return 0
206}
207
208func StepLimit(left, own time.Duration) time.Duration {
209 if left < 0 {
210 if own > 0 {
211 return own
212 }
213 return DefaultTimeout
214 }
215 if left <= 0 {
216 return 0
217 }
218 if own > 0 && own < left {
219 return own
220 }
221 return left
222}
223
224// Artifact declares an output of a job: a path pattern whose matches are uploaded
225// when the steps succeed. It is a declaration rather than a step that calls the
226// CLI, because a workflow is validated when it is pushed — a typo here fails the
227// push, while a shell command would fail at run time, possibly much later.
228type Artifact struct {
229 Path string
230 // Release publishes the matches as artifacts of the tag's release instead of
231 // only as this run's outputs. It requires a tag-triggered workflow: a branch run
232 // has no tag to attach to.
233 Release bool
234}
235
236// Service is one container started beside a job: a database, a queue, or another
237// server the steps talk to by name. It is not a job: it has no steps, no checkout,
238// no secrets, and its output is a log the runner attaches when the job fails. The
239// image is a registry reference because a service is an upstream container, not a
240// build.
241type Service struct {
242 // ID is the service's name and the hostname a step connects to.
243 ID string
244 Image string
245 // Env is the service's own environment, literal and bounded like a job's. It is
246 // not the job's environment and it is not where secrets arrive.
247 Env map[string]string
248}
249
250type Job struct {
251 ID string
252 // Name is the display name from `name:`, or the job ID when the job does not
253 // declare one. The ID stays the key a run and a command line use; this is what
254 // a reader sees.
255 Name string
256 // Description is an optional one-line summary of what the job does.
257 Description string
258 Steps []Step
259 // Labels are the runner labels this job requires (`runs-on`). Empty means any
260 // runner: a workflow that says nothing about where it runs keeps the behaviour it
261 // had before targeting existed. A job is only leased to a runner carrying every
262 // label here, so this is a filter on machines, not a preference among them.
263 Labels []string
264 // Network is the isolation the job asks for with `network:`, or empty when the
265 // workflow does not say. Empty is not "no network": it is "whatever the runner
266 // was started with", which is what every workflow meant before this field existed.
267 // A job that asks for NetworkBridge is only leased to a runner that was started
268 // able to provide it; a job that asks for NetworkNone gets none even from a runner
269 // that could provide bridge. The job decides, so the same workflow behaves the same
270 // way on every runner.
271 Network string
272 // Services are the containers started beside the job, in the order they were
273 // declared. A job with services must ask for NetworkBridge: the services are
274 // reached over the job's network, and a job that asked for none must never get one
275 // indirectly.
276 Services []Service
277 // Timeout is the wall clock the job's steps share, from `timeout:`, or zero when
278 // the job does not budget one. It is spent by the steps in order: a step that
279 // declares its own timeout is still cut short when the budget ends first, and a
280 // step that declares none runs with whatever is left. Zero keeps the behaviour a
281 // workflow had before the field: each step is bounded only by its own timeout.
282 Timeout time.Duration
283 // Bot names an account the job acts as, or is empty. The account must be a bot
284 // account, and the instance issues the run a credential for it, so a step can
285 // comment, open a change, or push a branch as that account without a long-lived
286 // key in a repository secret. The workflow carries the name only; the credential
287 // is minted when the job is leased and dies with the run.
288 Bot string
289 // Image is the image the job runs in. It is either a registry reference from
290 // `image:` or `file:<name>` for a definition under .vole/images.
291 Image string
292 // ImageFile names the custom definition when `image-file:` was used. It is empty
293 // for a registry image.
294 ImageFile string
295 // ImageDefinition is the pinned definition text for an image-file job. It is set
296 // when the workflow is validated against the tree, so a queued run keeps the
297 // definition it was queued from.
298 ImageDefinition string
299 // ImageSHA is the digest of ImageDefinition.
300 ImageSHA string
301 // Secrets are the repository secrets this job reads, by name. Each name is
302 // also the variable a step finds it in, and the workflow carries names only:
303 // the instance delivers the values when the job is leased. A declared name the
304 // repository has no value for leaves the variable unset, so a secret can be
305 // optional and a required one is ${NAME:?} in the step.
306 Secrets []string
307 // Env is the environment every step of this job runs with, on top of the
308 // image's own. It is where a job opts in to things the runner provides but does
309 // not decide for it, such as the cache mounted at /cache.
310 Env map[string]string
311 // Artifacts are the files this job publishes as the run's outputs.
312 Artifacts []Artifact
313 // Pages, when set, publishes a directory as the repository's site. It is on the
314 // job because a site is a build, and the job is the thing that builds.
315 Pages *Pages
316}
317
318// Pages declares a site deploy: one directory, published as the site branch's tree.
319// A directory rather than a glob, because "which tree is the site" has to have one
320// answer.
321type Pages struct {
322 Path string
323}
324
325// EventTrigger is the action list and event filters for a non-push trigger. An
326// absent action list is the trigger's default, which is documented in the
327// reference: the common actions, not every action the server can report.
328type EventTrigger struct {
329 Types []string
330 // Mentioned matches an event whose body mentions one of these accounts.
331 Mentioned []string
332 // RepliedTo matches a comment whose parent comment was written by one of these
333 // accounts. It is only meaningful on comment triggers.
334 RepliedTo []string
335 // AssignedTo matches an issue event that adds one of these accounts as an
336 // assignee. An issue opened with the account already assigned counts.
337 AssignedTo []string
338 // Author filters the account that caused the event: the comment author, the
339 // issue author, or the maintainer who assigned it. Name is an allowlist; Role
340 // is the minimum repository role, which is the ACL filter.
341 Author AuthorFilter
342}
343
344// AuthorFilter is the two things a trigger can say about the event sender. A
345// filter with neither field is rejected, so a misspelled author block cannot
346// quietly match everybody.
347type AuthorFilter struct {
348 Names []string
349 Role role.Role
350}
351
352// EventContext is what an event trigger is evaluated against. It is data taken
353// from the pinned event payload, never from the runner. A zero value is an event
354// with no body, no reply and no new assignees, which is what a schedule or a
355// manual run carries.
356type EventContext struct {
357 Kind string
358 Action string
359 Sender string
360 SenderRole role.Role
361 Mentions []string
362 ReplyToAuthor string
363 AssigneesAdded []string
364}
365
366// Schedule is one cron entry. Times are UTC, and the field syntax is the usual
367// five fields: minute, hour, day of month, month, day of week.
368type Schedule struct {
369 Cron string
370}
371
372// DispatchInput is one typed input a manual run may carry. The type decides what
373// value the server accepts; the workflow step sees the resolved value in the event
374// payload, never in an interpolated shell string.
375type DispatchInput struct {
376 Description string
377 Type string // string, boolean, number, choice
378 Required bool
379 Default string
380 DefaultSet bool
381 Options []string // for type choice
382}
383
384// Dispatch declares that a workflow can be run by hand, with typed inputs.
385type Dispatch struct {
386 Inputs map[string]DispatchInput
387}
388
389type Workflow struct {
390 Path string
391 // Name is the display name from `name:`, or the workflow file's base name when
392 // it does not declare one. The path stays the identity; this is what a reader
393 // sees.
394 Name string
395 // Description is an optional one-line summary of what the workflow is for.
396 Description string
397 // BlobSHA identifies the exact file contents queued for a run, so a workflow
398 // edited after enqueue cannot change what an already-queued job executes.
399 BlobSHA string
400 Version int
401 Branches []string
402 // Paths and PathsIgnore filter a push by the files it changes. An empty Paths
403 // means every path; PathsIgnore removes paths from that set. A push with no
404 // changed files matches neither filter.
405 Paths []string
406 PathsIgnore []string
407 // Tags are the tag patterns that trigger this ci. A tag run is how a
408 // release build happens: the tag names what is being released.
409 Tags []string
410 // ChangeRequest, Issues and Comments are metadata triggers. They are nil when
411 // the workflow does not listen to that event.
412 ChangeRequest *EventTrigger
413 Issues *EventTrigger
414 Comments *EventTrigger
415 Schedules []Schedule
416 Dispatch *Dispatch
417 Jobs []Job
418}
419
420// AppliesToTag reports whether this workflow should run for a tag push.
421func (w *Workflow) AppliesToTag(tag string) bool {
422 return matchRefPatterns(w.Tags, tag, false)
423}
424
425// Applies reports whether this workflow should run for a push to branch.
426//
427// "*" means every branch, including names with slashes. path.Match would treat it
428// as one path segment, which silently excluded contributor branches such as
429// contrib/<account-name>/fix. Other patterns keep path.Match's one-segment rule, so
430// "release/*" does not accidentally become "release/anything/at/all".
431//
432// A leading ! excludes what an earlier pattern included. The last matching pattern
433// wins, so ["*", "!main"] runs everywhere except main.
434func (w *Workflow) Applies(branch string) bool {
435 return matchRefPatterns(w.Branches, branch, true)
436}
437
438// AppliesToPush reports whether this workflow should run for a branch or tag push
439// with the given changed paths. Exactly one of branch and tag is expected.
440func (w *Workflow) AppliesToPush(branch, tag string, changed []string) bool {
441 switch {
442 case branch != "" && tag == "":
443 if !w.Applies(branch) {
444 return false
445 }
446 case tag != "" && branch == "":
447 if !w.AppliesToTag(tag) {
448 return false
449 }
450 default:
451 return false
452 }
453 return w.PathsMatch(changed)
454}
455
456// AppliesToEvent reports whether this workflow listens to one non-push event.
457func (w *Workflow) AppliesToEvent(ev EventContext) bool {
458 var trigger *EventTrigger
459 switch ev.Kind {
460 case "change_request":
461 trigger = w.ChangeRequest
462 case "issue":
463 trigger = w.Issues
464 case "comment":
465 trigger = w.Comments
466 case "schedule":
467 return len(w.Schedules) > 0
468 case "workflow_dispatch":
469 return w.Dispatch != nil
470 }
471 if trigger == nil || !containsString(trigger.Types, ev.Action) {
472 return false
473 }
474 return trigger.matches(ev)
475}
476
477// matches applies the filters to one event. Author and AssignedTo are requirements;
478// Mentioned and RepliedTo are alternatives, because a comment can address someone
479// either by naming them or by answering them. A comment never addresses its own
480// author, which is what keeps an agent's reply from waking itself.
481func (t *EventTrigger) matches(ev EventContext) bool {
482 if len(t.Author.Names) > 0 && !containsString(t.Author.Names, ev.Sender) {
483 return false
484 }
485 if t.Author.Role != role.None && !ev.SenderRole.AtLeast(t.Author.Role) {
486 return false
487 }
488 if len(t.AssignedTo) > 0 && !containsAny(t.AssignedTo, ev.AssigneesAdded) {
489 return false
490 }
491 if len(t.Mentioned) == 0 && len(t.RepliedTo) == 0 {
492 return true
493 }
494 if len(t.Mentioned) > 0 && mentionsAccount(t.Mentioned, ev.Mentions, ev.Sender) {
495 return true
496 }
497 if len(t.RepliedTo) > 0 && ev.ReplyToAuthor != "" && ev.ReplyToAuthor != ev.Sender &&
498 containsString(t.RepliedTo, ev.ReplyToAuthor) {
499 return true
500 }
501 return false
502}
503
504// mentionsAccount reports whether names contains one of want, ignoring a name the
505// event's own sender carries: a comment is not addressed to its author.
506func mentionsAccount(want, names []string, sender string) bool {
507 for _, name := range names {
508 if name == sender {
509 continue
510 }
511 if containsString(want, name) {
512 return true
513 }
514 }
515 return false
516}
517
518func containsAny(list, want []string) bool {
519 for _, value := range want {
520 if containsString(list, value) {
521 return true
522 }
523 }
524 return false
525}
526
527// PathsMatch applies the push path filters to the paths a push changed. An ignored
528// path is removed before the allow list is consulted, so an ignored file cannot
529// make a workflow run; when every changed path is ignored, nothing runs. A push
530// with no changed files matches only a workflow with no path filters.
531func (w *Workflow) PathsMatch(changed []string) bool {
532 if len(w.Paths) == 0 && len(w.PathsIgnore) == 0 {
533 return true
534 }
535 for _, changedPath := range changed {
536 if matchAnyPath(w.PathsIgnore, changedPath) {
537 continue
538 }
539 if len(w.Paths) == 0 || matchAnyPath(w.Paths, changedPath) {
540 return true
541 }
542 }
543 return false
544}
545
546func matchRefPatterns(patterns []string, name string, starMatchesAll bool) bool {
547 matched := false
548 for _, raw := range patterns {
549 pattern := strings.TrimSpace(raw)
550 negated := strings.HasPrefix(pattern, "!")
551 if negated {
552 pattern = strings.TrimPrefix(pattern, "!")
553 }
554 if pattern == "" {
555 continue
556 }
557 if starMatchesAll && pattern == "*" {
558 matched = !negated
559 continue
560 }
561 if ok, err := path.Match(pattern, name); err == nil && ok {
562 matched = !negated
563 }
564 }
565 return matched
566}
567
568func matchAnyPath(patterns []string, name string) bool {
569 for _, pattern := range patterns {
570 if matchPathPattern(pattern, name) {
571 return true
572 }
573 }
574 return false
575}
576
577// matchPathPattern matches one path pattern. A single * stays inside one segment;
578// ** spans zero or more segments, so docs/** covers the directory itself and every
579// file under it.
580func matchPathPattern(pattern, name string) bool {
581 return matchPathSegments(strings.Split(pattern, "/"), strings.Split(name, "/"))
582}
583
584func matchPathSegments(pattern, name []string) bool {
585 for len(pattern) > 0 {
586 if pattern[0] == "**" {
587 if matchPathSegments(pattern[1:], name) {
588 return true
589 }
590 if len(name) == 0 {
591 return false
592 }
593 name = name[1:]
594 continue
595 }
596 if len(name) == 0 {
597 return false
598 }
599 if ok, err := path.Match(pattern[0], name[0]); err != nil || !ok {
600 return false
601 }
602 pattern, name = pattern[1:], name[1:]
603 }
604 return len(name) == 0
605}
606
607func containsString(list []string, want string) bool {
608 for _, value := range list {
609 if value == want {
610 return true
611 }
612 }
613 return false
614}
615
616type rawStep struct {
617 Name string `yaml:"name"`
618 Run string `yaml:"run"`
619 Timeout string `yaml:"timeout"`
620}
621
622type rawArtifact struct {
623 Path string `yaml:"path"`
624 Release bool `yaml:"release"`
625}
626
627type rawPages struct {
628 Path string `yaml:"path"`
629}
630
631// rawEventTrigger accepts `change-request:`, `issue:` and `comment:` as either an
632// empty value (all the default actions) or a mapping with a types list and filter
633// fields beside it. It rejects unknown keys itself because a custom unmarshaler is
634// what makes the two spellings possible, and the outer decoder's KnownFields
635// cannot see inside the node.
636type rawEventTrigger struct {
637 Set bool
638 Types []string
639 Mentioned *[]string
640 RepliedTo *[]string
641 AssignedTo *[]string
642 Author *rawAuthorFilter
643}
644
645func (t *rawEventTrigger) UnmarshalYAML(node *yaml.Node) error {
646 t.Set = true
647 if node.Tag == "!!null" {
648 return nil
649 }
650 if node.Kind != yaml.MappingNode {
651 return fmt.Errorf("expects a mapping like {types: [opened], mentioned: [agent]} or an empty value")
652 }
653 for i := 0; i < len(node.Content); i += 2 {
654 key := node.Content[i].Value
655 value := node.Content[i+1]
656 switch key {
657 case "types":
658 if err := value.Decode(&t.Types); err != nil {
659 return err
660 }
661 case "mentioned":
662 list, err := decodeAccountList(value)
663 if err != nil {
664 return fmt.Errorf("mentioned: %w", err)
665 }
666 t.Mentioned = &list
667 case "replied-to":
668 list, err := decodeAccountList(value)
669 if err != nil {
670 return fmt.Errorf("replied-to: %w", err)
671 }
672 t.RepliedTo = &list
673 case "assigned-to":
674 list, err := decodeAccountList(value)
675 if err != nil {
676 return fmt.Errorf("assigned-to: %w", err)
677 }
678 t.AssignedTo = &list
679 case "author":
680 var filter rawAuthorFilter
681 if err := value.Decode(&filter); err != nil {
682 return fmt.Errorf("author: %w", err)
683 }
684 t.Author = &filter
685 default:
686 return fmt.Errorf("field %s is not a known key here", key)
687 }
688 }
689 return nil
690}
691
692// rawAuthorFilter accepts the author as one account, a list, or a mapping with
693// name and role. The scalar and list forms are shorthand for name only, so the
694// common filter stays short.
695type rawAuthorFilter struct {
696 Names *[]string
697 Role *string
698}
699
700func (a *rawAuthorFilter) UnmarshalYAML(node *yaml.Node) error {
701 if node.Tag == "!!null" {
702 return nil
703 }
704 switch node.Kind {
705 case yaml.ScalarNode, yaml.SequenceNode:
706 list, err := decodeAccountList(node)
707 if err != nil {
708 return err
709 }
710 a.Names = &list
711 return nil
712 case yaml.MappingNode:
713 default:
714 return fmt.Errorf("expects an account, a list of accounts, or a mapping with name and role")
715 }
716 for i := 0; i < len(node.Content); i += 2 {
717 key := node.Content[i].Value
718 value := node.Content[i+1]
719 switch key {
720 case "name":
721 list, err := decodeAccountList(value)
722 if err != nil {
723 return fmt.Errorf("name: %w", err)
724 }
725 a.Names = &list
726 case "role":
727 if value.Kind != yaml.ScalarNode {
728 return fmt.Errorf("role expects one role")
729 }
730 var one string
731 if err := value.Decode(&one); err != nil {
732 return err
733 }
734 a.Role = &one
735 default:
736 return fmt.Errorf("field author.%s is not a known key here", key)
737 }
738 }
739 return nil
740}
741
742// decodeAccountList accepts one account or a list, the same way runs-on accepts
743// one label or a list. A filter written as a scalar is the obvious thing to try.
744func decodeAccountList(node *yaml.Node) ([]string, error) {
745 if node.Tag == "!!null" {
746 return nil, nil
747 }
748 if node.Kind == yaml.ScalarNode {
749 var one string
750 if err := node.Decode(&one); err != nil {
751 return nil, err
752 }
753 return []string{one}, nil
754 }
755 var list []string
756 if err := node.Decode(&list); err != nil {
757 return nil, err
758 }
759 return list, nil
760}
761
762type rawSchedule struct {
763 Cron string `yaml:"cron"`
764}
765
766// rawDispatch accepts `workflow-dispatch:` as an empty value or a mapping with
767// inputs. The same strict-key rule as rawEventTrigger applies.
768type rawDispatch struct {
769 Set bool
770 Inputs map[string]rawDispatchInput
771}
772
773type rawDispatchInput struct {
774 Description string
775 Type string
776 Required bool
777 Default string
778 DefaultSet bool
779 Options []string
780}
781
782func (t *rawDispatch) UnmarshalYAML(node *yaml.Node) error {
783 t.Set = true
784 if node.Tag == "!!null" {
785 return nil
786 }
787 if node.Kind != yaml.MappingNode {
788 return fmt.Errorf("expects a mapping like {inputs: {...}} or an empty value")
789 }
790 for i := 0; i < len(node.Content); i += 2 {
791 key := node.Content[i].Value
792 if key != "inputs" {
793 return fmt.Errorf("field %s is not a known key here", key)
794 }
795 value := node.Content[i+1]
796 if value.Tag == "!!null" {
797 continue
798 }
799 if value.Kind != yaml.MappingNode {
800 return fmt.Errorf("inputs expects a mapping of names to input definitions")
801 }
802 t.Inputs = map[string]rawDispatchInput{}
803 for j := 0; j < len(value.Content); j += 2 {
804 name := value.Content[j].Value
805 in, err := decodeDispatchInput(value.Content[j+1])
806 if err != nil {
807 return fmt.Errorf("inputs.%s: %w", name, err)
808 }
809 t.Inputs[name] = in
810 }
811 }
812 return nil
813}
814
815func decodeDispatchInput(node *yaml.Node) (rawDispatchInput, error) {
816 var in rawDispatchInput
817 if node.Tag == "!!null" {
818 return in, nil
819 }
820 if node.Kind != yaml.MappingNode {
821 return in, fmt.Errorf("expects a mapping of description, type, required, default and options")
822 }
823 for i := 0; i < len(node.Content); i += 2 {
824 key := node.Content[i].Value
825 value := node.Content[i+1]
826 switch key {
827 case "description":
828 if err := value.Decode(&in.Description); err != nil {
829 return in, err
830 }
831 case "type":
832 if err := value.Decode(&in.Type); err != nil {
833 return in, err
834 }
835 case "required":
836 if err := value.Decode(&in.Required); err != nil {
837 return in, err
838 }
839 case "default":
840 // The value is kept as the text YAML saw, so a quoted "false" and a bare
841 // false both reach the type check as the same string.
842 in.Default = value.Value
843 in.DefaultSet = true
844 case "options":
845 if err := value.Decode(&in.Options); err != nil {
846 return in, err
847 }
848 default:
849 return in, fmt.Errorf("field %s is not a known key here", key)
850 }
851 }
852 return in, nil
853}
854
855type rawService struct {
856 Image string `yaml:"image"`
857 Env map[string]string `yaml:"env"`
858}
859
860type rawJob struct {
861 Name string `yaml:"name"`
862 Description string `yaml:"description"`
863 Steps []rawStep `yaml:"steps"`
864 RunsOn rawLabels `yaml:"runs-on"`
865 Network string `yaml:"network"`
866 Services map[string]rawService `yaml:"services"`
867 Timeout string `yaml:"timeout"`
868 ActsAs string `yaml:"acts-as"`
869 Image string `yaml:"image"`
870 ImageFile string `yaml:"image-file"`
871 Artifacts []rawArtifact `yaml:"artifacts"`
872 Pages *rawPages `yaml:"pages"`
873 Secrets rawSecretNames `yaml:"secrets"`
874 Env map[string]string `yaml:"env"`
875}
876
877// rawSecretNames accepts one name or a list, the same way runs-on takes one label
878// or a list: `secrets: DEPLOY_KEY` when there is one is the obvious thing to write.
879// Only names are accepted here, so a value in a workflow file is a parse error
880// rather than a secret that quietly ends up in Git.
881type rawSecretNames []string
882
883func (s *rawSecretNames) UnmarshalYAML(node *yaml.Node) error {
884 if node.Tag == "!!null" {
885 return nil
886 }
887 switch node.Kind {
888 case yaml.ScalarNode:
889 var one string
890 if err := node.Decode(&one); err != nil {
891 return err
892 }
893 *s = []string{one}
894 return nil
895 case yaml.SequenceNode:
896 var list []string
897 if err := node.Decode(&list); err != nil {
898 return err
899 }
900 *s = list
901 return nil
902 }
903 return fmt.Errorf("secrets takes a name or a list of names (secrets: DEPLOY_KEY, or secrets: [DEPLOY_KEY, TOKEN])")
904}
905
906// rawLabels accepts both spellings of runs-on, because both are the obvious way to
907// write it: `runs-on: linux` when there is one label and `runs-on: [linux, arm64]`
908// when there are several. Making the scalar a decode error would be a dialect
909// difference with no purpose — the dialect is separate from GitHub's already, so it
910// should not also fail on the spelling a person guessed.
911type rawLabels []string
912
913func (l *rawLabels) UnmarshalYAML(node *yaml.Node) error {
914 switch node.Kind {
915 case yaml.ScalarNode:
916 var s string
917 if err := node.Decode(&s); err != nil {
918 return err
919 }
920 *l = []string{s}
921 return nil
922 case yaml.SequenceNode:
923 var v []string
924 if err := node.Decode(&v); err != nil {
925 return err
926 }
927 *l = v
928 return nil
929 }
930 return fmt.Errorf("runs-on takes a label or a list of labels (runs-on: linux, or runs-on: [linux, arm64])")
931}
932
933type rawWorkflow struct {
934 Version int `yaml:"version"`
935 Name string `yaml:"name"`
936 Description string `yaml:"description"`
937 On struct {
938 Push rawPush `yaml:"push"`
939 ChangeRequest yaml.Node `yaml:"change-request"`
940 Issue yaml.Node `yaml:"issue"`
941 Comment yaml.Node `yaml:"comment"`
942 Schedule []rawSchedule `yaml:"schedule"`
943 WorkflowDispatch yaml.Node `yaml:"workflow-dispatch"`
944 } `yaml:"on"`
945 Jobs map[string]rawJob `yaml:"jobs"`
946}
947
948// rawPush records that the push key was present. A workflow with only event
949// triggers must not acquire the ordinary every-branch push default merely because
950// it said nothing about branches.
951type rawPush struct {
952 Set bool
953 Branches *[]string
954 Tags *[]string
955 Paths *[]string
956 PathsIgnore *[]string
957}
958
959func (p *rawPush) UnmarshalYAML(node *yaml.Node) error {
960 p.Set = true
961 if node.Tag == "!!null" {
962 return nil
963 }
964 if node.Kind != yaml.MappingNode {
965 return fmt.Errorf("push expects a mapping")
966 }
967 for i := 0; i < len(node.Content); i += 2 {
968 key := node.Content[i].Value
969 value := node.Content[i+1]
970 switch key {
971 case "branches":
972 var v []string
973 if err := value.Decode(&v); err != nil {
974 return err
975 }
976 p.Branches = &v
977 case "tags":
978 var v []string
979 if err := value.Decode(&v); err != nil {
980 return err
981 }
982 p.Tags = &v
983 case "paths":
984 var v []string
985 if err := value.Decode(&v); err != nil {
986 return err
987 }
988 p.Paths = &v
989 case "paths-ignore":
990 var v []string
991 if err := value.Decode(&v); err != nil {
992 return err
993 }
994 p.PathsIgnore = &v
995 default:
996 return fmt.Errorf("field %s is not a known key here", key)
997 }
998 }
999 return nil
1000}
1001
1002// Parse validates one workflow file. Every problem is reported, not just the first.
1003func Parse(file string, content string) (*Workflow, []string) {
1004 var problems []string
1005 bad := func(format string, args ...any) {
1006 problems = append(problems, fmt.Sprintf("%s: %s", file, fmt.Sprintf(format, args...)))
1007 }
1008
1009 if len(content) > MaxFileBytes {
1010 bad("file is %d bytes, limit %d", len(content), MaxFileBytes)
1011 return nil, problems
1012 }
1013
1014 var rw rawWorkflow
1015 dec := yaml.NewDecoder(strings.NewReader(content))
1016 dec.KnownFields(true)
1017 if err := dec.Decode(&rw); err != nil {
1018 if err.Error() == "EOF" {
1019 bad("file is empty")
1020 return nil, problems
1021 }
1022 bad("%s", tidyYAMLError(err))
1023 return nil, problems
1024 }
1025
1026 if rw.Version == 0 {
1027 bad("version is required (currently %d)", Version)
1028 } else if rw.Version != Version {
1029 bad("version %d is not supported; this server runs version %d", rw.Version, Version)
1030 }
1031
1032 wf := &Workflow{Path: file, Version: rw.Version}
1033 if name, problem := displayText("name", rw.Name, MaxNameBytes); problem != "" {
1034 bad("%s", problem)
1035 } else if name != "" {
1036 wf.Name = name
1037 } else {
1038 // A workflow whose file does not declare a name is still called something:
1039 // the file's base name is the identity the path already gives it.
1040 wf.Name = workflowStem(file)
1041 }
1042 if desc, problem := displayText("description", rw.Description, MaxDescriptionBytes); problem != "" {
1043 bad("%s", problem)
1044 } else {
1045 wf.Description = desc
1046 }
1047 // Tags have no default: a workflow that runs on a tag push has to say so, or a
1048 // release build would fire on every push.
1049 if rw.On.Push.Tags != nil {
1050 for _, tg := range *rw.On.Push.Tags {
1051 pattern := strings.TrimPrefix(strings.TrimSpace(tg), "!")
1052 if pattern == "" || !branchGlob.MatchString(tg) || strings.Contains(tg, "..") {
1053 bad("on.push.tags: %q is not a tag pattern", tg)
1054 }
1055 wf.Tags = append(wf.Tags, tg)
1056 }
1057 }
1058 if rw.On.Push.Paths != nil {
1059 wf.Paths = validPaths(*rw.On.Push.Paths, "on.push.paths", bad)
1060 }
1061 if rw.On.Push.PathsIgnore != nil {
1062 wf.PathsIgnore = validPaths(*rw.On.Push.PathsIgnore, "on.push.paths-ignore", bad)
1063 }
1064 if trigger, problem := parseEventNode(&rw.On.ChangeRequest, "on.change-request",
1065 changeRequestActions, defaultChangeRequestActions, []string{"mentioned", "author"}); problem != "" {
1066 bad("%s", problem)
1067 } else {
1068 wf.ChangeRequest = trigger
1069 }
1070 if trigger, problem := parseEventNode(&rw.On.Issue, "on.issue",
1071 issueActions, defaultIssueActions, []string{"mentioned", "assigned-to", "author"}); problem != "" {
1072 bad("%s", problem)
1073 } else {
1074 wf.Issues = trigger
1075 }
1076 if trigger, problem := parseEventNode(&rw.On.Comment, "on.comment",
1077 commentActions, defaultCommentActions, []string{"mentioned", "replied-to", "author"}); problem != "" {
1078 bad("%s", problem)
1079 } else {
1080 wf.Comments = trigger
1081 }
1082 if len(rw.On.Schedule) > MaxSchedules {
1083 bad("on.schedule: %d entries, limit %d", len(rw.On.Schedule), MaxSchedules)
1084 }
1085 for i, rs := range rw.On.Schedule {
1086 cron := strings.TrimSpace(rs.Cron)
1087 if cron == "" {
1088 bad("on.schedule[%d]: cron is required", i)
1089 continue
1090 }
1091 if _, err := ParseCron(cron); err != nil {
1092 bad("on.schedule[%d].cron: %v", i, err)
1093 continue
1094 }
1095 wf.Schedules = append(wf.Schedules, Schedule{Cron: cron})
1096 }
1097 if rw.On.WorkflowDispatch.Kind != 0 {
1098 var raw rawDispatch
1099 if err := raw.UnmarshalYAML(&rw.On.WorkflowDispatch); err != nil {
1100 bad("on.workflow-dispatch: %v", err)
1101 } else if dispatch, problems := parseDispatch(raw); len(problems) > 0 {
1102 for _, problem := range problems {
1103 bad("%s", problem)
1104 }
1105 } else {
1106 wf.Dispatch = dispatch
1107 }
1108 }
1109
1110 switch {
1111 case rw.On.Push.Branches != nil:
1112 wf.Branches = *rw.On.Push.Branches
1113 case rw.On.Push.Tags != nil:
1114 // Tags have no branch default: a workflow that runs on a tag says so.
1115 case rw.On.Push.Set:
1116 // An empty push trigger means every branch, as it always has.
1117 wf.Branches = []string{"*"}
1118 case wf.ChangeRequest == nil && wf.Issues == nil && wf.Comments == nil &&
1119 len(wf.Schedules) == 0 && wf.Dispatch == nil:
1120 // No trigger at all was declared, so this is an ordinary push ci.
1121 wf.Branches = []string{"*"}
1122 }
1123
1124 for _, b := range wf.Branches {
1125 pattern := strings.TrimPrefix(strings.TrimSpace(b), "!")
1126 if pattern == "" || !branchGlob.MatchString(b) || strings.Contains(b, "..") {
1127 bad("on.push.branches: %q is not a branch pattern", b)
1128 }
1129 }
1130
1131 if len(rw.Jobs) == 0 {
1132 bad("at least one job is required")
1133 }
1134 if len(rw.Jobs) > MaxJobs {
1135 bad("%d jobs, limit %d", len(rw.Jobs), MaxJobs)
1136 }
1137
1138 // One site, one publisher: a second job declaring pages would race the first,
1139 // and the winner would depend on which runner finished last.
1140 pagesJobs := 0
1141 ids := make([]string, 0, len(rw.Jobs))
1142 for id := range rw.Jobs {
1143 ids = append(ids, id)
1144 }
1145 sort.Strings(ids)
1146 for _, id := range ids {
1147 jr := rw.Jobs[id]
1148 if !jobIDRE.MatchString(id) {
1149 bad("jobs.%s: job names are 1-32 characters, lowercase letters, digits, - and _, starting with a letter", id)
1150 }
1151 if len(jr.Steps) == 0 {
1152 bad("jobs.%s: at least one step is required", id)
1153 continue
1154 }
1155 if len(jr.Steps) > MaxSteps {
1156 bad("jobs.%s: %d steps, limit %d", id, len(jr.Steps), MaxSteps)
1157 }
1158 job := Job{ID: id, Name: id}
1159 if name, problem := displayText("jobs."+id+".name", jr.Name, MaxNameBytes); problem != "" {
1160 bad("%s", problem)
1161 } else if name != "" {
1162 job.Name = name
1163 }
1164 if desc, problem := displayText("jobs."+id+".description", jr.Description, MaxDescriptionBytes); problem != "" {
1165 bad("%s", problem)
1166 } else {
1167 job.Description = desc
1168 }
1169 // Every job names the environment it runs in. Either a registry image or a
1170 // definition under .vole/images; one or the other, never both and never
1171 // neither. The definition content is resolved in CheckWithImages, where the
1172 // rest of the tree is available.
1173 image := strings.TrimSpace(jr.Image)
1174 imageFile := strings.TrimSpace(jr.ImageFile)
1175 switch {
1176 case image != "" && imageFile != "":
1177 bad("jobs.%s: image and image-file are mutually exclusive", id)
1178 case image == "" && imageFile == "":
1179 bad("jobs.%s: image or image-file is required", id)
1180 case image != "":
1181 if err := ValidImageRef(image); err != nil {
1182 bad("jobs.%s.image: %v", id, err)
1183 } else {
1184 job.Image = image
1185 }
1186 default:
1187 name, err := ImageName(imageFile)
1188 if err != nil {
1189 bad("jobs.%s.image-file: %v", id, err)
1190 } else {
1191 job.ImageFile = name
1192 job.Image = "file:" + name
1193 }
1194 }
1195 // runs-on is validated here rather than at lease time: a label a job asks for and
1196 // no runner can ever carry leaves the job queued forever, which looks like a busy
1197 // pool rather than a mistake. A typo fails the push that introduced it.
1198 if len(jr.RunsOn) > MaxLabels {
1199 bad("jobs.%s: %d runs-on labels, limit %d", id, len(jr.RunsOn), MaxLabels)
1200 }
1201 seenLabels := map[string]bool{}
1202 for _, raw := range jr.RunsOn {
1203 label := strings.TrimSpace(raw)
1204 if label == "" {
1205 bad("jobs.%s.runs-on: an empty label names no runner", id)
1206 continue
1207 }
1208 if err := ValidLabel(label); err != nil {
1209 bad("jobs.%s.runs-on: %v", id, err)
1210 continue
1211 }
1212 if seenLabels[label] {
1213 continue // a repeated label asks for nothing more than one does
1214 }
1215 seenLabels[label] = true
1216 job.Labels = append(job.Labels, label)
1217 }
1218 // network is a request, not a machine: a job that needs to reach something
1219 // outside the container says so, and the queue only hands it to a runner that
1220 // can keep the promise. It is validated here for the same reason runs-on is: a
1221 // value no runner can satisfy would leave the job queued forever.
1222 if network := strings.TrimSpace(jr.Network); network != "" {
1223 if err := ValidNetwork(network); err != nil {
1224 bad("jobs.%s.network: %v", id, err)
1225 } else {
1226 job.Network = network
1227 }
1228 }
1229 // acts-as names the bot account the job acts as. The name is checked here so
1230 // a typo fails the push; whether it is a bot account, is active, and may act
1231 // in this repository is checked when the job is leased, because a roster
1232 // changes without a push.
1233 if actsAs := strings.TrimSpace(jr.ActsAs); actsAs != "" {
1234 if !accountRE.MatchString(actsAs) {
1235 bad("jobs.%s.acts-as: %q is not an account name (2-32 characters: lowercase letters, digits, - and _)", id, actsAs)
1236 } else {
1237 job.Bot = actsAs
1238 }
1239 }
1240 // A job budget is the clock its steps share. It is optional: without one each
1241 // step is still bounded, which is how every workflow worked before the field.
1242 if raw := strings.TrimSpace(jr.Timeout); raw != "" {
1243 d, err := time.ParseDuration(raw)
1244 switch {
1245 case err != nil:
1246 bad("jobs.%s.timeout: %q is not a duration (try 90s, 10m, 2h)", id, raw)
1247 case d <= 0:
1248 bad("jobs.%s.timeout: must be positive", id)
1249 case d > MaxTimeout:
1250 bad("jobs.%s.timeout: %s exceeds the %s limit", id, d, MaxTimeout)
1251 default:
1252 job.Timeout = d
1253 }
1254 }
1255 for i, sr := range jr.Steps {
1256 where := fmt.Sprintf("jobs.%s.steps[%d]", id, i)
1257 run := strings.TrimSpace(sr.Run)
1258 if run == "" {
1259 bad("%s: run is required (this runs a shell command)", where)
1260 continue
1261 }
1262 if len(sr.Run) > MaxStepBytes {
1263 bad("%s: run is %d bytes, limit %d", where, len(sr.Run), MaxStepBytes)
1264 }
1265 step := Step{Name: strings.TrimSpace(sr.Name), Run: run}
1266 if sr.Timeout != "" {
1267 d, err := time.ParseDuration(sr.Timeout)
1268 switch {
1269 case err != nil:
1270 bad("%s.timeout: %q is not a duration (try 90s, 10m)", where, sr.Timeout)
1271 case d <= 0:
1272 bad("%s.timeout: must be positive", where)
1273 case d > MaxTimeout:
1274 bad("%s.timeout: %s exceeds the %s limit", where, d, MaxTimeout)
1275 case job.Timeout > 0 && d > job.Timeout:
1276 // The job's budget would end first, so the step could never use the
1277 // timeout it asked for. That is a mistake, not a preference.
1278 bad("%s.timeout: %s is longer than the job's timeout %s", where, d, job.Timeout)
1279 default:
1280 step.Timeout = d
1281 }
1282 }
1283 if step.Name == "" {
1284 step.Name = firstLine(run)
1285 }
1286 job.Steps = append(job.Steps, step)
1287 }
1288 if len(jr.Artifacts) > MaxArtifacts {
1289 bad("jobs.%s: %d artifact declarations, limit %d", id, len(jr.Artifacts), MaxArtifacts)
1290 }
1291 for i, ar := range jr.Artifacts {
1292 where := fmt.Sprintf("jobs.%s.artifacts[%d]", id, i)
1293 p := strings.TrimSpace(ar.Path)
1294 if err := ValidateArtifactPath(p); err != nil {
1295 bad("%s: %v", where, err)
1296 continue
1297 }
1298 if ar.Release && len(wf.Tags) == 0 {
1299 bad("%s: release artifacts need a tag trigger; add on.push.tags so the build "+
1300 "knows which release it is publishing", where)
1301 continue
1302 }
1303 job.Artifacts = append(job.Artifacts, Artifact{Path: p, Release: ar.Release})
1304 }
1305 // Secrets are names. The value is the instance's, not the workflow's, so a
1306 // name that is not stored is not a push error: the job runs without that
1307 // variable and its log says which one was missing.
1308 seenSecrets := map[string]bool{}
1309 for _, raw := range jr.Secrets {
1310 name := strings.TrimSpace(raw)
1311 if err := ValidateSecretName(name); err != nil {
1312 bad("jobs.%s.secrets: %v", id, err)
1313 continue
1314 }
1315 if reservedEnv[name] {
1316 bad("jobs.%s.secrets: %s is set by the runner, so a secret cannot replace it", id, name)
1317 continue
1318 }
1319 if seenSecrets[name] {
1320 bad("jobs.%s.secrets: %s is listed twice", id, name)
1321 continue
1322 }
1323 seenSecrets[name] = true
1324 job.Secrets = append(job.Secrets, name)
1325 }
1326 // The job's own environment. The runner sets a small fixed set (CI,
1327 // VOLE_CI, HOME, TMPDIR, GIT_TERMINAL_PROMPT) that the sandbox depends on,
1328 // so a workflow cannot replace those: the refusal names the variable rather
1329 // than letting docker's last-wins rule silently disagree with the sandbox.
1330 if len(jr.Env) > MaxEnv {
1331 bad("jobs.%s: %d environment variables, limit %d", id, len(jr.Env), MaxEnv)
1332 }
1333 names := make([]string, 0, len(jr.Env))
1334 for name := range jr.Env {
1335 names = append(names, name)
1336 }
1337 sort.Strings(names)
1338 for _, name := range names {
1339 value := jr.Env[name]
1340 switch {
1341 case !envNameRE.MatchString(name):
1342 bad("jobs.%s.env: %q is not a variable name (letters, digits and _, not starting with a digit)", id, name)
1343 continue
1344 case reservedEnv[name]:
1345 bad("jobs.%s.env: %s is set by the runner and cannot be replaced", id, name)
1346 continue
1347 case strings.ContainsAny(value, "\r\n"):
1348 bad("jobs.%s.env.%s: the value must be one line", id, name)
1349 continue
1350 case len(value) > MaxEnvValue:
1351 bad("jobs.%s.env.%s: the value is %d bytes, limit %d", id, name, len(value), MaxEnvValue)
1352 continue
1353 }
1354 if job.Env == nil {
1355 job.Env = map[string]string{}
1356 }
1357 job.Env[name] = value
1358 }
1359 // Services are containers beside the job. They are reached over the job's
1360 // network, so the job has to ask for one: a service that quietly added a network
1361 // would break the promise `network: none` makes, and a job on a runner whose
1362 // default is none would look stuck rather than misconfigured.
1363 if len(jr.Services) > MaxServices {
1364 bad("jobs.%s: %d services, limit %d", id, len(jr.Services), MaxServices)
1365 }
1366 serviceNames := make([]string, 0, len(jr.Services))
1367 for name := range jr.Services {
1368 serviceNames = append(serviceNames, name)
1369 }
1370 sort.Strings(serviceNames)
1371 for _, name := range serviceNames {
1372 where := fmt.Sprintf("jobs.%s.services.%s", id, name)
1373 if !serviceNameRE.MatchString(name) {
1374 bad("%s: a service name is 1-32 characters, lowercase letters, digits and -, starting with a letter", where)
1375 continue
1376 }
1377 rs := jr.Services[name]
1378 service := Service{ID: name}
1379 image := strings.TrimSpace(rs.Image)
1380 switch {
1381 case image == "":
1382 bad("%s.image: an image is required", where)
1383 default:
1384 if err := ValidImageRef(image); err != nil {
1385 bad("%s.image: %v", where, err)
1386 } else {
1387 service.Image = image
1388 }
1389 }
1390 if len(rs.Env) > MaxEnv {
1391 bad("%s: %d environment variables, limit %d", where, len(rs.Env), MaxEnv)
1392 }
1393 envNames := make([]string, 0, len(rs.Env))
1394 for envName := range rs.Env {
1395 envNames = append(envNames, envName)
1396 }
1397 sort.Strings(envNames)
1398 for _, envName := range envNames {
1399 value := rs.Env[envName]
1400 switch {
1401 case !envNameRE.MatchString(envName):
1402 bad("%s.env: %q is not a variable name (letters, digits and _, not starting with a digit)", where, envName)
1403 continue
1404 case strings.ContainsAny(value, "\r\n"):
1405 bad("%s.env.%s: the value must be one line", where, envName)
1406 continue
1407 case len(value) > MaxEnvValue:
1408 bad("%s.env.%s: the value is %d bytes, limit %d", where, envName, len(value), MaxEnvValue)
1409 continue
1410 }
1411 if service.Env == nil {
1412 service.Env = map[string]string{}
1413 }
1414 service.Env[envName] = value
1415 }
1416 job.Services = append(job.Services, service)
1417 }
1418 if len(jr.Services) > 0 && job.Network != NetworkBridge {
1419 bad("jobs.%s.services: services need network: bridge, because a step reaches them over the job's network", id)
1420 }
1421 if jr.Pages != nil {
1422 where := fmt.Sprintf("jobs.%s.pages.path", id)
1423 p := strings.TrimSpace(jr.Pages.Path)
1424 if err := ValidatePagesPath(p); err != nil {
1425 bad("%s: %v", where, err)
1426 } else {
1427 job.Pages = &Pages{Path: strings.TrimSuffix(p, "/")}
1428 if pagesJobs++; pagesJobs > 1 {
1429 bad("jobs.%s: only one job per workflow may publish the site; two jobs would "+
1430 "race to be the site's tree", id)
1431 }
1432 }
1433 }
1434 wf.Jobs = append(wf.Jobs, job)
1435 }
1436 if len(problems) > 0 {
1437 return nil, problems
1438 }
1439 return wf, nil
1440}
1441
1442// Check validates workflow files given as path to contents, e.g. a pushed tree. It
1443// has no image definitions to resolve, so a workflow that uses image-file is
1444// reported as missing its definition. Callers with a tree use CheckWithImages.
1445func Check(files map[string]string) ([]*Workflow, []string) {
1446 return CheckWithImages(files, nil)
1447}
1448
1449// CheckWithImages validates workflows and the .vole/images definitions they
1450// reference. Every image file is parsed, even when unreferenced, so a broken
1451// definition fails the push that introduced it rather than a later build.
1452func CheckWithImages(files, images map[string]string) ([]*Workflow, []string) {
1453 var out []*Workflow
1454 var problems []string
1455
1456 paths := make([]string, 0, len(files))
1457 for p := range files {
1458 paths = append(paths, p)
1459 }
1460 sort.Strings(paths)
1461
1462 for _, p := range paths {
1463 if !strings.HasPrefix(p, Dir+"/") {
1464 continue
1465 }
1466 rel := strings.TrimPrefix(p, Dir+"/")
1467 if strings.Contains(rel, "/") {
1468 problems = append(problems, fmt.Sprintf(
1469 "%s: workflows live directly in %s, not in subdirectories", p, Dir))
1470 continue
1471 }
1472 if !strings.HasSuffix(rel, ".yaml") && !strings.HasSuffix(rel, ".yml") {
1473 problems = append(problems, fmt.Sprintf(
1474 "%s: a workflow file must end in .yaml or .yml", p))
1475 continue
1476 }
1477 wf, probs := Parse(p, files[p])
1478 problems = append(problems, probs...)
1479 if wf != nil {
1480 out = append(out, wf)
1481 }
1482 }
1483
1484 // One name per definition. A go.yaml and a go.yml would make image-file: go
1485 // ambiguous, which is a mistake better caught here than at build time.
1486 type foundImage struct {
1487 path string
1488 content string
1489 }
1490 byName := map[string]foundImage{}
1491 imagePaths := make([]string, 0, len(images))
1492 for p := range images {
1493 imagePaths = append(imagePaths, p)
1494 }
1495 sort.Strings(imagePaths)
1496 for _, p := range imagePaths {
1497 if !strings.HasPrefix(p, ImageDir+"/") {
1498 continue
1499 }
1500 rel := strings.TrimPrefix(p, ImageDir+"/")
1501 if strings.Contains(rel, "/") {
1502 problems = append(problems, fmt.Sprintf(
1503 "%s: image definitions live directly in %s, not in subdirectories", p, ImageDir))
1504 continue
1505 }
1506 if !strings.HasSuffix(rel, ".yaml") && !strings.HasSuffix(rel, ".yml") {
1507 problems = append(problems, fmt.Sprintf(
1508 "%s: an image definition must end in .yaml or .yml", p))
1509 continue
1510 }
1511 name, err := ImageName(rel)
1512 if err != nil {
1513 problems = append(problems, fmt.Sprintf("%s: %v", p, err))
1514 continue
1515 }
1516 def, probs := ParseImage(p, images[p])
1517 problems = append(problems, probs...)
1518 if def == nil {
1519 continue
1520 }
1521 if prev, exists := byName[name]; exists {
1522 problems = append(problems, fmt.Sprintf(
1523 "%s: image name %q is already defined by %s", p, name, prev.path))
1524 continue
1525 }
1526 byName[name] = foundImage{path: p, content: images[p]}
1527 }
1528
1529 for _, wf := range out {
1530 for i := range wf.Jobs {
1531 job := &wf.Jobs[i]
1532 if job.ImageFile == "" {
1533 continue
1534 }
1535 found, ok := byName[job.ImageFile]
1536 if !ok {
1537 problems = append(problems, fmt.Sprintf(
1538 "%s: jobs.%s.image-file: %q has no %s",
1539 wf.Path, job.ID, job.ImageFile, ImagePath(job.ImageFile)))
1540 continue
1541 }
1542 job.ImageDefinition = found.content
1543 job.ImageSHA = ImageDigest(found.content)
1544 }
1545 }
1546 return out, problems
1547}
1548
1549// validPaths trims and validates a path-filter list. Path filters cannot climb out
1550// of the tree, and they cannot be empty: a pattern that matches nothing would leave
1551// a workflow permanently silent.
1552func validPaths(list []string, where string, bad func(string, ...any)) []string {
1553 if len(list) > MaxPaths {
1554 bad("%s: %d patterns, limit %d", where, len(list), MaxPaths)
1555 }
1556 out := make([]string, 0, len(list))
1557 for _, raw := range list {
1558 p := strings.TrimSpace(raw)
1559 switch {
1560 case p == "":
1561 bad("%s: an empty pattern matches no path", where)
1562 continue
1563 case strings.HasPrefix(p, "/"):
1564 bad("%s: %q must be relative to the repository root", where, p)
1565 continue
1566 case strings.Contains(p, ".."):
1567 bad("%s: %q must not contain ..", where, p)
1568 continue
1569 case !pathGlob.MatchString(p):
1570 bad("%s: %q is not a path pattern", where, p)
1571 continue
1572 }
1573 out = append(out, p)
1574 }
1575 return out
1576}
1577
1578// parseEventNode handles an event key written as an empty value (`issue:`) or as a
1579// mapping. The outer decoder skips a null into a struct field, so the key is read
1580// as a yaml.Node and absence (Kind 0) stays different from "present and empty".
1581func parseEventNode(node *yaml.Node, where string, allowed, defaults, allowedFilters []string) (*EventTrigger, string) {
1582 if node.Kind == 0 {
1583 return nil, ""
1584 }
1585 var raw rawEventTrigger
1586 if err := raw.UnmarshalYAML(node); err != nil {
1587 return nil, fmt.Sprintf("%s: %v", where, err)
1588 }
1589 return parseEventTrigger(raw, where, allowed, defaults, allowedFilters)
1590}
1591
1592func parseEventTrigger(raw rawEventTrigger, where string, allowed, defaults, allowedFilters []string) (*EventTrigger, string) {
1593 if !raw.Set {
1594 return nil, ""
1595 }
1596 types := raw.Types
1597 if len(types) == 0 {
1598 types = defaults
1599 }
1600 seen := map[string]bool{}
1601 out := make([]string, 0, len(types))
1602 for _, value := range types {
1603 action := strings.TrimSpace(value)
1604 if !containsString(allowed, action) {
1605 return nil, fmt.Sprintf("%s.types: %q is not an action (%s)", where, value, strings.Join(allowed, ", "))
1606 }
1607 if seen[action] {
1608 continue
1609 }
1610 seen[action] = true
1611 out = append(out, action)
1612 }
1613 trigger := &EventTrigger{Types: out}
1614 for _, filter := range []struct {
1615 name string
1616 values *[]string
1617 dest *[]string
1618 }{
1619 {"mentioned", raw.Mentioned, &trigger.Mentioned},
1620 {"replied-to", raw.RepliedTo, &trigger.RepliedTo},
1621 {"assigned-to", raw.AssignedTo, &trigger.AssignedTo},
1622 } {
1623 if filter.values == nil {
1624 continue
1625 }
1626 if !containsString(allowedFilters, filter.name) {
1627 return nil, fmt.Sprintf("%s.%s is not available on this trigger", where, filter.name)
1628 }
1629 names, problem := parseAccountFilter(where+"."+filter.name, *filter.values)
1630 if problem != "" {
1631 return nil, problem
1632 }
1633 *filter.dest = names
1634 }
1635 if raw.Author != nil {
1636 if !containsString(allowedFilters, "author") {
1637 return nil, fmt.Sprintf("%s.author is not available on this trigger", where)
1638 }
1639 if raw.Author.Names != nil {
1640 names, problem := parseAccountFilter(where+".author.name", *raw.Author.Names)
1641 if problem != "" {
1642 return nil, problem
1643 }
1644 trigger.Author.Names = names
1645 }
1646 if raw.Author.Role != nil {
1647 parsed, err := role.Parse(*raw.Author.Role)
1648 if err != nil {
1649 return nil, fmt.Sprintf("%s.author.role: %v", where, err)
1650 }
1651 if parsed == role.None {
1652 return nil, fmt.Sprintf("%s.author.role: use read, contribute, maintain or admin", where)
1653 }
1654 trigger.Author.Role = parsed
1655 }
1656 if len(trigger.Author.Names) == 0 && trigger.Author.Role == role.None {
1657 return nil, fmt.Sprintf("%s.author: give name, role, or both", where)
1658 }
1659 }
1660 return trigger, ""
1661}
1662
1663func parseAccountFilter(where string, values []string) ([]string, string) {
1664 if len(values) == 0 {
1665 return nil, fmt.Sprintf("%s: needs at least one account", where)
1666 }
1667 if len(values) > MaxFilterAccounts {
1668 return nil, fmt.Sprintf("%s: %d accounts, limit %d", where, len(values), MaxFilterAccounts)
1669 }
1670 seen := map[string]bool{}
1671 out := make([]string, 0, len(values))
1672 for _, raw := range values {
1673 name := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(raw), "@"))
1674 if name == "" {
1675 return nil, fmt.Sprintf("%s: an empty account name is not a filter", where)
1676 }
1677 if !accountRE.MatchString(name) {
1678 return nil, fmt.Sprintf("%s: %q is not an account name (2-32 characters: lowercase letters, digits, - and _)", where, raw)
1679 }
1680 if seen[name] {
1681 continue
1682 }
1683 seen[name] = true
1684 out = append(out, name)
1685 }
1686 sort.Strings(out)
1687 return out, ""
1688}
1689
1690func parseDispatch(raw rawDispatch) (*Dispatch, []string) {
1691 var problems []string
1692 dispatch := &Dispatch{Inputs: map[string]DispatchInput{}}
1693 if len(raw.Inputs) > MaxDispatchInputs {
1694 problems = append(problems, fmt.Sprintf("on.workflow-dispatch.inputs: %d inputs, limit %d",
1695 len(raw.Inputs), MaxDispatchInputs))
1696 }
1697 names := make([]string, 0, len(raw.Inputs))
1698 for name := range raw.Inputs {
1699 names = append(names, name)
1700 }
1701 sort.Strings(names)
1702 for _, name := range names {
1703 where := "on.workflow-dispatch.inputs." + name
1704 rawInput := raw.Inputs[name]
1705 if !dispatchInputRE.MatchString(name) {
1706 problems = append(problems, fmt.Sprintf("%s: names are 1-32 characters, letters, digits, _ and -, starting with a letter or _", where))
1707 continue
1708 }
1709 inputType := strings.TrimSpace(rawInput.Type)
1710 if inputType == "" {
1711 inputType = "string"
1712 }
1713 switch inputType {
1714 case "string", "boolean", "number", "choice":
1715 default:
1716 problems = append(problems, fmt.Sprintf("%s.type: %q is not a type (string, boolean, number, choice)", where, inputType))
1717 continue
1718 }
1719 description := strings.TrimSpace(rawInput.Description)
1720 if len(description) > MaxDescriptionBytes {
1721 problems = append(problems, fmt.Sprintf("%s.description: %d bytes, limit %d", where, len(description), MaxDescriptionBytes))
1722 }
1723 if strings.ContainsFunc(description, func(r rune) bool {
1724 return r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029'
1725 }) {
1726 problems = append(problems, fmt.Sprintf("%s.description: must be one line of text", where))
1727 }
1728 defaultValue := strings.TrimSpace(rawInput.Default)
1729 if rawInput.DefaultSet && len(defaultValue) > MaxDispatchValueBytes {
1730 problems = append(problems, fmt.Sprintf("%s.default: %d bytes, limit %d", where, len(defaultValue), MaxDispatchValueBytes))
1731 }
1732 options := make([]string, 0, len(rawInput.Options))
1733 seenOptions := map[string]bool{}
1734 for _, option := range rawInput.Options {
1735 option = strings.TrimSpace(option)
1736 if option == "" {
1737 problems = append(problems, fmt.Sprintf("%s.options: an empty choice is not a value", where))
1738 continue
1739 }
1740 if seenOptions[option] {
1741 continue
1742 }
1743 seenOptions[option] = true
1744 options = append(options, option)
1745 }
1746 if inputType == "choice" {
1747 if len(options) == 0 {
1748 problems = append(problems, fmt.Sprintf("%s.options: a choice needs at least one option", where))
1749 }
1750 if rawInput.DefaultSet && !seenOptions[defaultValue] {
1751 problems = append(problems, fmt.Sprintf("%s.default: %q is not one of the options", where, defaultValue))
1752 }
1753 } else if len(options) > 0 {
1754 problems = append(problems, fmt.Sprintf("%s.options: only a choice input takes options", where))
1755 }
1756 if rawInput.DefaultSet {
1757 if problem := validDispatchValue(inputType, defaultValue, options); problem != "" {
1758 problems = append(problems, fmt.Sprintf("%s.default: %s", where, problem))
1759 }
1760 }
1761 dispatch.Inputs[name] = DispatchInput{
1762 Description: description,
1763 Type: inputType,
1764 Required: rawInput.Required,
1765 Default: defaultValue,
1766 DefaultSet: rawInput.DefaultSet,
1767 Options: options,
1768 }
1769 }
1770 if len(problems) > 0 {
1771 return nil, problems
1772 }
1773 return dispatch, nil
1774}
1775
1776// ResolveDispatchInputs applies the declared types, defaults and required flags to
1777// a manual run's values. It returns the values a step should see; an optional input
1778// with no value is absent rather than an empty string, so a step can tell "not set"
1779// from "set to empty".
1780func (w *Workflow) ResolveDispatchInputs(given map[string]string) (map[string]string, []string) {
1781 if w.Dispatch == nil {
1782 return nil, []string{"this workflow has no workflow-dispatch trigger"}
1783 }
1784 var problems []string
1785 for name := range given {
1786 if _, ok := w.Dispatch.Inputs[name]; !ok {
1787 problems = append(problems, fmt.Sprintf("%q is not a declared input", name))
1788 }
1789 }
1790 names := make([]string, 0, len(w.Dispatch.Inputs))
1791 for name := range w.Dispatch.Inputs {
1792 names = append(names, name)
1793 }
1794 sort.Strings(names)
1795 values := map[string]string{}
1796 for _, name := range names {
1797 decl := w.Dispatch.Inputs[name]
1798 value, givenValue := given[name]
1799 if !givenValue {
1800 if !decl.DefaultSet {
1801 if decl.Required {
1802 problems = append(problems, fmt.Sprintf("%s is required", name))
1803 }
1804 continue
1805 }
1806 value = decl.Default
1807 }
1808 if len(value) > MaxDispatchValueBytes {
1809 problems = append(problems, fmt.Sprintf("%s: %d bytes, limit %d", name, len(value), MaxDispatchValueBytes))
1810 continue
1811 }
1812 if strings.ContainsAny(value, "\r\n\x00") {
1813 problems = append(problems, fmt.Sprintf("%s: must be one line", name))
1814 continue
1815 }
1816 if problem := validDispatchValue(decl.Type, value, decl.Options); problem != "" {
1817 problems = append(problems, fmt.Sprintf("%s: %s", name, problem))
1818 continue
1819 }
1820 values[name] = value
1821 }
1822 if len(problems) > 0 {
1823 return nil, problems
1824 }
1825 return values, nil
1826}
1827
1828func validDispatchValue(kind, value string, options []string) string {
1829 switch kind {
1830 case "boolean":
1831 if _, err := strconv.ParseBool(value); err != nil {
1832 return fmt.Sprintf("%q is not true or false", value)
1833 }
1834 case "number":
1835 if _, err := strconv.ParseFloat(value, 64); err != nil {
1836 return fmt.Sprintf("%q is not a number", value)
1837 }
1838 case "choice":
1839 if !containsString(options, value) {
1840 return fmt.Sprintf("%q is not one of %s", value, strings.Join(options, ", "))
1841 }
1842 }
1843 return ""
1844}
1845
1846// Cron is one parsed five-field schedule. The fields are minute, hour, day of
1847// month, month, and day of week, in UTC. It supports *, lists, ranges, and a step
1848// on * or a range. Names and @shorthands are not part of the format.
1849type Cron struct {
1850 minute, hour, dayOfMonth, month, dayOfWeek uint64
1851 dayOfMonthAny, dayOfWeekAny bool
1852}
1853
1854// ParseCron parses one schedule expression.
1855func ParseCron(spec string) (*Cron, error) {
1856 fields := strings.Fields(spec)
1857 if len(fields) != 5 {
1858 return nil, fmt.Errorf("%q needs 5 fields (minute hour day month weekday)", spec)
1859 }
1860 c := &Cron{}
1861 var err error
1862 if c.minute, _, err = parseCronField(fields[0], 0, 59, false); err != nil {
1863 return nil, fmt.Errorf("minute: %w", err)
1864 }
1865 if c.hour, _, err = parseCronField(fields[1], 0, 23, false); err != nil {
1866 return nil, fmt.Errorf("hour: %w", err)
1867 }
1868 if c.dayOfMonth, c.dayOfMonthAny, err = parseCronField(fields[2], 1, 31, false); err != nil {
1869 return nil, fmt.Errorf("day of month: %w", err)
1870 }
1871 if c.month, _, err = parseCronField(fields[3], 1, 12, false); err != nil {
1872 return nil, fmt.Errorf("month: %w", err)
1873 }
1874 if c.dayOfWeek, c.dayOfWeekAny, err = parseCronField(fields[4], 0, 7, true); err != nil {
1875 return nil, fmt.Errorf("day of week: %w", err)
1876 }
1877 // Sunday is both 0 and 7. Normalising here means a schedule that says 7 and
1878 // one that says 0 cannot disagree about the same day.
1879 if c.dayOfWeek&(1<<7) != 0 {
1880 c.dayOfWeek |= 1
1881 c.dayOfWeek &^= 1 << 7
1882 }
1883 return c, nil
1884}
1885
1886// Matches reports whether t falls in this schedule's minute, hour, and date.
1887func (c *Cron) Matches(t time.Time) bool {
1888 if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 ||
1889 c.month&(1<<uint(int(t.Month()))) == 0 {
1890 return false
1891 }
1892 dayOfMonth := c.dayOfMonth&(1<<uint(t.Day())) != 0
1893 dayOfWeek := c.dayOfWeek&(1<<uint(int(t.Weekday()))) != 0
1894 // Cron's odd rule: when both date fields are restricted, either one may match.
1895 if c.dayOfMonthAny || c.dayOfWeekAny {
1896 return dayOfMonth && dayOfWeek
1897 }
1898 return dayOfMonth || dayOfWeek
1899}
1900
1901func parseCronField(field string, min, max int, allowSeven bool) (uint64, bool, error) {
1902 var mask uint64
1903 any := false
1904 for _, part := range strings.Split(field, ",") {
1905 part = strings.TrimSpace(part)
1906 if part == "" {
1907 return 0, false, fmt.Errorf("empty list entry")
1908 }
1909 original := part
1910 step := 1
1911 if i := strings.IndexByte(part, '/'); i >= 0 {
1912 n, err := strconv.Atoi(part[i+1:])
1913 if err != nil || n <= 0 {
1914 return 0, false, fmt.Errorf("%q has an invalid step", original)
1915 }
1916 step = n
1917 part = part[:i]
1918 }
1919 start, end := min, max
1920 switch {
1921 case part == "*":
1922 if step == 1 {
1923 any = true
1924 }
1925 case strings.Contains(part, "-"):
1926 bounds := strings.SplitN(part, "-", 2)
1927 var err error
1928 if start, err = strconv.Atoi(bounds[0]); err != nil {
1929 return 0, false, fmt.Errorf("%q is not a range", original)
1930 }
1931 if end, err = strconv.Atoi(bounds[1]); err != nil {
1932 return 0, false, fmt.Errorf("%q is not a range", original)
1933 }
1934 default:
1935 if step != 1 {
1936 return 0, false, fmt.Errorf("%q needs a range or * before /%d", original, step)
1937 }
1938 n, err := strconv.Atoi(part)
1939 if err != nil {
1940 return 0, false, fmt.Errorf("%q is not a number", original)
1941 }
1942 start, end = n, n
1943 }
1944 if start < min || end > max || start > end {
1945 return 0, false, fmt.Errorf("%q is outside %d-%d", original, min, max)
1946 }
1947 for n := start; n <= end; n += step {
1948 if allowSeven && n == 7 {
1949 mask |= 1 << 7
1950 continue
1951 }
1952 mask |= 1 << uint(n)
1953 }
1954 }
1955 return mask, any, nil
1956}
1957
1958// tidyYAMLError drops the Go type name yaml.v3 leaks into its messages, which
1959// means nothing to someone editing a ci.
1960func tidyYAMLError(err error) string {
1961 msg := err.Error()
1962 msg = strings.ReplaceAll(msg, "yaml: unmarshal errors:\n ", "")
1963 for _, suffix := range []string{
1964 " not found in type ci.rawJob",
1965 " not found in type ci.rawStep",
1966 " not found in type ci.rawWorkflow",
1967 " not found in type ci.rawImageDef",
1968 " not found in type ci.raw",
1969 } {
1970 msg = strings.ReplaceAll(msg, suffix, " is not a known key here")
1971 }
1972 msg = strings.ReplaceAll(msg, "yaml: ", "")
1973 return strings.TrimSpace(msg)
1974}
1975
1976// displayText trims a display name or description and reports why it cannot be
1977// shown on a page. An absent value is not a problem: the caller supplies the
1978// default, such as the job ID.
1979func displayText(where, value string, max int) (text, problem string) {
1980 text = strings.TrimSpace(value)
1981 switch {
1982 case text == "":
1983 return "", ""
1984 case len(text) > max:
1985 return "", fmt.Sprintf("%s: %d bytes, limit %d", where, len(text), max)
1986 case strings.ContainsFunc(text, func(r rune) bool {
1987 return r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029'
1988 }):
1989 return "", fmt.Sprintf("%s: must be one line of text", where)
1990 }
1991 return text, ""
1992}
1993
1994// workflowStem is the display name a workflow gets when it declares none: the
1995// file's base name without its .yaml or .yml extension.
1996func workflowStem(file string) string {
1997 base := file
1998 if i := strings.LastIndexByte(base, '/'); i >= 0 {
1999 base = base[i+1:]
2000 }
2001 return strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml")
2002}
2003
2004func firstLine(s string) string {
2005 if i := strings.IndexByte(s, '\n'); i >= 0 {
2006 s = s[:i]
2007 }
2008 s = strings.TrimSpace(s)
2009 if len(s) > 60 {
2010 s = s[:57] + "..."
2011 }
2012 return s
2013}
2014
2015// At loads and validates the workflows in a git tree, with the blob id of each
2016// file so a run can be pinned to the version it was queued from.
2017func At(g *git.Repo, commit string) ([]*Workflow, []string) {
2018 if commit == "" || commit == git.ZeroSHA {
2019 return nil, nil
2020 }
2021 blobs, err := g.LsTreeR(commit)
2022 if err != nil {
2023 return nil, []string{fmt.Sprintf("cannot read %s: %v", Dir, err)}
2024 }
2025 files := map[string]string{}
2026 images := map[string]string{}
2027 shas := map[string]string{}
2028 for _, e := range blobs {
2029 switch {
2030 case strings.HasPrefix(e.Name, Dir+"/"):
2031 data, err := g.BlobBySHA(e.SHA)
2032 if err != nil {
2033 return nil, []string{fmt.Sprintf("cannot read %s: %v", e.Name, err)}
2034 }
2035 files[e.Name] = string(data)
2036 shas[e.Name] = e.SHA
2037 case strings.HasPrefix(e.Name, ImageDir+"/"):
2038 data, err := g.BlobBySHA(e.SHA)
2039 if err != nil {
2040 return nil, []string{fmt.Sprintf("cannot read %s: %v", e.Name, err)}
2041 }
2042 images[e.Name] = string(data)
2043 }
2044 }
2045 wfs, problems := CheckWithImages(files, images)
2046 for _, wf := range wfs {
2047 wf.BlobSHA = shas[wf.Path]
2048 }
2049 return wfs, problems
2050}
2051
2052// ValidateTree checks the workflows in a git tree. Called from pre-receive, so a
2053// bad workflow fails the push that introduced it.
2054func ValidateTree(g *git.Repo, commit string) []string {
2055 if commit == "" || commit == git.ZeroSHA {
2056 return nil
2057 }
2058 blobs, err := g.LsTreeR(commit)
2059 if err != nil {
2060 return []string{fmt.Sprintf("cannot read %s: %v", Dir, err)}
2061 }
2062 files := map[string]string{}
2063 images := map[string]string{}
2064 for _, e := range blobs {
2065 switch {
2066 case strings.HasPrefix(e.Name, Dir+"/"):
2067 data, err := g.BlobBySHA(e.SHA)
2068 if err != nil {
2069 return []string{fmt.Sprintf("cannot read %s: %v", e.Name, err)}
2070 }
2071 files[e.Name] = string(data)
2072 case strings.HasPrefix(e.Name, ImageDir+"/"):
2073 data, err := g.BlobBySHA(e.SHA)
2074 if err != nil {
2075 return []string{fmt.Sprintf("cannot read %s: %v", e.Name, err)}
2076 }
2077 images[e.Name] = string(data)
2078 }
2079 }
2080 _, problems := CheckWithImages(files, images)
2081 return problems
2082}
2083
2084// CheckDir validates the workflows in a working tree, for `vole workflow check`.
2085func CheckDir(root string) ([]*Workflow, []string, error) {
2086 read := func(dir string, into map[string]string) error {
2087 return filepath.WalkDir(filepath.Join(root, filepath.FromSlash(dir)), func(p string, d fs.DirEntry, err error) error {
2088 if err != nil {
2089 if os.IsNotExist(err) {
2090 return nil
2091 }
2092 return err
2093 }
2094 if d.IsDir() {
2095 return nil
2096 }
2097 if !strings.HasSuffix(d.Name(), ".yaml") && !strings.HasSuffix(d.Name(), ".yml") {
2098 return nil
2099 }
2100 data, err := os.ReadFile(p)
2101 if err != nil {
2102 return err
2103 }
2104 rel, err := filepath.Rel(root, p)
2105 if err != nil {
2106 rel = p
2107 }
2108 into[filepath.ToSlash(rel)] = string(data)
2109 return nil
2110 })
2111 }
2112 files := map[string]string{}
2113 images := map[string]string{}
2114 if err := read(Dir, files); err != nil {
2115 return nil, nil, err
2116 }
2117 if err := read(ImageDir, images); err != nil {
2118 return nil, nil, err
2119 }
2120 wfs, problems := CheckWithImages(files, images)
2121 return wfs, problems, nil
2122}
2123