internal/ci/ci.go
133 lines · 4626 bytes
9 symbols in this file
1// Package ci parses .gitbay/ci.yml, the per-repo build configuration:
2//
3// jobs:
4// test:
5// steps:
6// - go test ./...
7//
8// Each job becomes one build per push; each step is a shell command the
9// runner executes with `sh -c`, stopping at the first failure.
10package ci
11
12import (
13 "fmt"
14 "path"
15 "regexp"
16 "sort"
17
18 yaml "go.yaml.in/yaml/v3"
19)
20
21// ConfigPath is where the build configuration lives in a repository.
22const ConfigPath = ".gitbay/ci.yml"
23
24const (
25 maxJobs = 10
26 maxSteps = 50
27 maxStepSize = 4096
28 maxPaths = 50
29)
30
31var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
32
33// imageRef matches an OCI image reference conservatively: registry path
34// segments, an optional :tag and an optional @sha256: digest. This string
35// becomes an argument to `podman run`, and a repository's config file must
36// not be able to turn it into anything else — so the pattern allows only
37// what a reference needs and refuses whitespace and every shell character
38// rather than trying to escape them (#144).
39var imageRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\-]*(:[0-9]+)?(/[a-zA-Z0-9][a-zA-Z0-9._\-]*)*(:[a-zA-Z0-9][a-zA-Z0-9._\-]{0,127})?(@sha256:[a-f0-9]{64})?$`)
40
41type Job struct {
42 Name string
43 Steps []string
44 Schedule string // cron expression; scheduled jobs run on schedule, not on push
45 Tags string // tag glob (e.g. "v*"); tag jobs run on matching tag pushes only
46 // Paths and PathsIgnore only gate a job queued on push; a scheduled
47 // or tag job ignores them.
48 Paths []string // globs; the job runs only when a changed file matches one
49 PathsIgnore []string // globs; the job is skipped when every changed file matches one
50 // Image is the container image the job's steps run in. Empty means
51 // the runner's configured default (#144).
52 Image string
53}
54
55// Parse returns the jobs in name order, or an error describing the first
56// problem so the pusher can fix the file.
57func Parse(raw []byte) ([]Job, error) {
58 var doc struct {
59 Jobs map[string]struct {
60 Steps []string `yaml:"steps"`
61 Schedule string `yaml:"schedule"`
62 Tags string `yaml:"tags"`
63 Paths []string `yaml:"paths"`
64 PathsIgnore []string `yaml:"paths-ignore"`
65 Image string `yaml:"image"`
66 } `yaml:"jobs"`
67 }
68 if err := yaml.Unmarshal(raw, &doc); err != nil {
69 return nil, fmt.Errorf("parsing %s: %w", ConfigPath, err)
70 }
71 if len(doc.Jobs) == 0 {
72 return nil, fmt.Errorf("%s defines no jobs", ConfigPath)
73 }
74 if len(doc.Jobs) > maxJobs {
75 return nil, fmt.Errorf("%s defines %d jobs; max %d", ConfigPath, len(doc.Jobs), maxJobs)
76 }
77 var jobs []Job
78 for name, j := range doc.Jobs {
79 if !jobName.MatchString(name) {
80 return nil, fmt.Errorf("bad job name %q: lowercase letters, digits, - and _; max 40 chars", name)
81 }
82 if len(j.Steps) == 0 {
83 return nil, fmt.Errorf("job %q has no steps", name)
84 }
85 if len(j.Steps) > maxSteps {
86 return nil, fmt.Errorf("job %q has %d steps; max %d", name, len(j.Steps), maxSteps)
87 }
88 for _, s := range j.Steps {
89 if len(s) > maxStepSize {
90 return nil, fmt.Errorf("job %q has a step over %d bytes", name, maxStepSize)
91 }
92 }
93 if j.Schedule != "" {
94 if _, err := ParseCron(j.Schedule); err != nil {
95 return nil, fmt.Errorf("job %q: %v", name, err)
96 }
97 }
98 if j.Tags != "" {
99 if _, err := path.Match(j.Tags, "x"); err != nil {
100 return nil, fmt.Errorf("job %q: bad tag pattern %q", name, j.Tags)
101 }
102 if j.Schedule != "" {
103 return nil, fmt.Errorf("job %q: schedule and tags are mutually exclusive", name)
104 }
105 }
106 if len(j.Paths) > maxPaths {
107 return nil, fmt.Errorf("job %q has %d path patterns; max %d", name, len(j.Paths), maxPaths)
108 }
109 for _, p := range j.Paths {
110 if _, err := path.Match(p, "x"); err != nil {
111 return nil, fmt.Errorf("job %q: bad path pattern %q", name, p)
112 }
113 }
114 if len(j.PathsIgnore) > maxPaths {
115 return nil, fmt.Errorf("job %q has %d paths-ignore patterns; max %d", name, len(j.PathsIgnore), maxPaths)
116 }
117 for _, p := range j.PathsIgnore {
118 if _, err := path.Match(p, "x"); err != nil {
119 return nil, fmt.Errorf("job %q: bad paths-ignore pattern %q", name, p)
120 }
121 }
122 if j.Image != "" && !imageRef.MatchString(j.Image) {
123 return nil, fmt.Errorf("job %q: bad image %q: a reference like "+
124 "docker.io/library/alpine:3.20, not a command line", name, j.Image)
125 }
126 jobs = append(jobs, Job{
127 Name: name, Steps: j.Steps, Schedule: j.Schedule, Tags: j.Tags,
128 Paths: j.Paths, PathsIgnore: j.PathsIgnore, Image: j.Image,
129 })
130 }
131 sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name })
132 return jobs, nil
133}