internal/ci/ci.go

e3632a550366fe23b2619edba30fc58fab19b598
gitbay/internal/ci/ci.go history · blame · raw

133 lines · 4626 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	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}