internal/ci/ci.go

96df83f2d3eb9f241bcaa53fcc243d090c53ab2b
gitbay/internal/ci/ci.go history · blame · raw

91 lines · 2616 bytes

 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)
29
30var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
31
32type Job struct {
33	Name     string
34	Steps    []string
35	Schedule string // cron expression; scheduled jobs run on schedule, not on push
36	Tags     string // tag glob (e.g. "v*"); tag jobs run on matching tag pushes only
37}
38
39// Parse returns the jobs in name order, or an error describing the first
40// problem so the pusher can fix the file.
41func Parse(raw []byte) ([]Job, error) {
42	var doc struct {
43		Jobs map[string]struct {
44			Steps    []string `yaml:"steps"`
45			Schedule string   `yaml:"schedule"`
46			Tags     string   `yaml:"tags"`
47		} `yaml:"jobs"`
48	}
49	if err := yaml.Unmarshal(raw, &doc); err != nil {
50		return nil, fmt.Errorf("parsing %s: %w", ConfigPath, err)
51	}
52	if len(doc.Jobs) == 0 {
53		return nil, fmt.Errorf("%s defines no jobs", ConfigPath)
54	}
55	if len(doc.Jobs) > maxJobs {
56		return nil, fmt.Errorf("%s defines %d jobs; max %d", ConfigPath, len(doc.Jobs), maxJobs)
57	}
58	var jobs []Job
59	for name, j := range doc.Jobs {
60		if !jobName.MatchString(name) {
61			return nil, fmt.Errorf("bad job name %q: lowercase letters, digits, - and _; max 40 chars", name)
62		}
63		if len(j.Steps) == 0 {
64			return nil, fmt.Errorf("job %q has no steps", name)
65		}
66		if len(j.Steps) > maxSteps {
67			return nil, fmt.Errorf("job %q has %d steps; max %d", name, len(j.Steps), maxSteps)
68		}
69		for _, s := range j.Steps {
70			if len(s) > maxStepSize {
71				return nil, fmt.Errorf("job %q has a step over %d bytes", name, maxStepSize)
72			}
73		}
74		if j.Schedule != "" {
75			if _, err := ParseCron(j.Schedule); err != nil {
76				return nil, fmt.Errorf("job %q: %v", name, err)
77			}
78		}
79		if j.Tags != "" {
80			if _, err := path.Match(j.Tags, "x"); err != nil {
81				return nil, fmt.Errorf("job %q: bad tag pattern %q", name, j.Tags)
82			}
83			if j.Schedule != "" {
84				return nil, fmt.Errorf("job %q: schedule and tags are mutually exclusive", name)
85			}
86		}
87		jobs = append(jobs, Job{Name: name, Steps: j.Steps, Schedule: j.Schedule, Tags: j.Tags})
88	}
89	sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name })
90	return jobs, nil
91}