manifest.go

  1// Package manifest loads and validates workflow manifests
  2// (.tangled/workflows/<name>.yml with engine devenv) and matches their
  3// triggers.
  4//
  5// YAML is decoded generically, its shorthands are normalised (a string for a
  6// list, `gate: true`, `concurrency: <group>`), and the result is decoded into
  7// lex.Workflow through strict JSON, so the YAML and JSON forms share one
  8// validation path and unknown fields are errors.
  9package manifest
 10
 11import (
 12	"encoding/json"
 13	"errors"
 14	"fmt"
 15	"os"
 16	"path"
 17	"path/filepath"
 18	"regexp"
 19	"slices"
 20	"sort"
 21	"strings"
 22	"time"
 23
 24	"gopkg.in/yaml.v3"
 25
 26	"git.kilimanjaro.io/ci/internal/lex"
 27)
 28
 29// Dir is where workflow files live, relative to the repository root.
 30const Dir = ".tangled/workflows"
 31
 32// DefaultGateTimeout applies when a gate declares none.
 33const DefaultGateTimeout = 24 * time.Hour
 34
 35var (
 36	actionName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
 37	envName    = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
 38)
 39
 40// ErrOtherEngine is returned by Parse for a workflow that doesn't use the
 41// devenv engine: it belongs to another runner and is skipped, not invalid.
 42var ErrOtherEngine = errors.New("not a devenv workflow")
 43
 44// Manifest is a parsed, validated workflow.
 45type Manifest struct {
 46	Name string // file name without extension
 47	Raw  []byte // the file as written
 48	lex.Workflow
 49}
 50
 51// Load reads and parses the workflow file at p.
 52func Load(p string) (*Manifest, error) {
 53	b, err := os.ReadFile(p)
 54	if err != nil {
 55		return nil, err
 56	}
 57	m, err := Parse(NameOf(p), b)
 58	if err != nil {
 59		return nil, fmt.Errorf("%s: %w", p, err)
 60	}
 61	return m, nil
 62}
 63
 64// NameOf returns a workflow's name: its file name without .yml/.yaml.
 65func NameOf(p string) string {
 66	base := path.Base(filepath.ToSlash(p))
 67	return strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml")
 68}
 69
 70// Find returns the path of the named workflow under root.
 71func Find(root, name string) (string, error) {
 72	for _, ext := range []string{".yml", ".yaml"} {
 73		p := filepath.Join(root, Dir, name+ext)
 74		if _, err := os.Stat(p); err == nil {
 75			return p, nil
 76		}
 77	}
 78	return "", fmt.Errorf("no workflow %q in %s", name, filepath.Join(root, Dir))
 79}
 80
 81// List returns the workflow files under root, sorted.
 82func List(root string) ([]string, error) {
 83	var out []string
 84	for _, pat := range []string{"*.yml", "*.yaml"} {
 85		m, err := filepath.Glob(filepath.Join(root, Dir, pat))
 86		if err != nil {
 87			return nil, err
 88		}
 89		out = append(out, m...)
 90	}
 91	sort.Strings(out)
 92	return out, nil
 93}
 94
 95// Parse decodes and validates a manifest.
 96func Parse(name string, b []byte) (*Manifest, error) {
 97	var doc any
 98	if err := yaml.Unmarshal(b, &doc); err != nil {
 99		return nil, err
100	}
101	top, ok := doc.(map[string]any)
102	if !ok {
103		return nil, fmt.Errorf("a workflow must be a mapping")
104	}
105	if e, _ := top["engine"].(string); e != lex.EngineDevenv {
106		return nil, fmt.Errorf("%w (engine %q)", ErrOtherEngine, e)
107	}
108	if err := normalise(top); err != nil {
109		return nil, err
110	}
111	j, err := json.Marshal(top)
112	if err != nil {
113		return nil, err
114	}
115	m := &Manifest{Name: name, Raw: b}
116	if err := lex.StrictUnmarshal(j, &m.Workflow); err != nil {
117		return nil, err
118	}
119	if err := m.validate(); err != nil {
120		return nil, err
121	}
122	return m, nil
123}
124
125// normalise rewrites YAML shorthands into the lexicon's JSON shape, in place.
126func normalise(top map[string]any) error {
127	if when, ok := top["when"].([]any); ok {
128		for i, c := range when {
129			cm, ok := c.(map[string]any)
130			if !ok {
131				return fmt.Errorf("when[%d]: must be a mapping", i)
132			}
133			for _, k := range []string{"event", "branch", "tag", "paths"} {
134				if s, ok := cm[k].(string); ok {
135					cm[k] = []any{s}
136				}
137			}
138		}
139	}
140	if acts, ok := top["actions"].(map[string]any); ok {
141		for name, a := range acts {
142			am, ok := a.(map[string]any)
143			if !ok {
144				return fmt.Errorf("actions.%s: must be a mapping", name)
145			}
146			switch g := am["gate"].(type) {
147			case bool:
148				if g {
149					am["gate"] = map[string]any{}
150				} else {
151					delete(am, "gate")
152				}
153			case nil:
154				if _, present := am["gate"]; present {
155					am["gate"] = map[string]any{}
156				}
157			}
158			if s, ok := am["concurrency"].(string); ok {
159				am["concurrency"] = map[string]any{"group": s}
160			}
161		}
162	}
163	if env, ok := top["environment"].(map[string]any); ok {
164		for k, v := range env {
165			switch v.(type) {
166			case string:
167			case int, int64, float64, bool:
168				env[k] = fmt.Sprint(v)
169			default:
170				return fmt.Errorf("environment.%s: must be a string", k)
171			}
172		}
173	}
174	return nil
175}
176
177func (m *Manifest) validate() error {
178	var errs []error
179	bad := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
180
181	if len(m.Steps) == 0 {
182		bad("steps: at least one step is required")
183	}
184	seen := map[string]bool{}
185	for i, s := range m.Steps {
186		if _, ok := m.Actions[s]; !ok {
187			bad("steps[%d]: %q is not a declared action", i, s)
188		}
189		if seen[s] {
190			bad("steps[%d]: %q appears twice (a result can route back to a step instead)", i, s)
191		}
192		seen[s] = true
193	}
194	names := make([]string, 0, len(m.Actions))
195	for n := range m.Actions {
196		names = append(names, n)
197	}
198	sort.Strings(names)
199	for _, n := range names {
200		a := m.Actions[n]
201		if !actionName.MatchString(n) || n == lex.ThenStop {
202			bad("actions.%s: invalid name (letters, digits, . _ -; not %q)", n, lex.ThenStop)
203		}
204		if strings.TrimSpace(a.Command) == "" {
205			bad("actions.%s: command is required", n)
206		}
207		if a.Dir != "" {
208			if err := relPath(a.Dir); err != nil {
209				bad("actions.%s.dir: %v", n, err)
210			}
211		}
212		if a.Style != "" && a.Style != "normal" && a.Style != "danger" {
213			bad("actions.%s.style: %q (want normal or danger)", n, a.Style)
214		}
215		if a.Gate != nil && a.Gate.Timeout != "" {
216			if d, err := time.ParseDuration(a.Gate.Timeout); err != nil || d <= 0 {
217				bad("actions.%s.gate.timeout: %q is not a positive duration", n, a.Gate.Timeout)
218			}
219		}
220		if a.Concurrency != nil && a.Concurrency.Group == "" {
221			bad("actions.%s.concurrency: group is required", n)
222		}
223	}
224	paths := map[string]bool{}
225	for i, r := range m.Repos {
226		if r.Repo == "" {
227			bad("repos[%d]: repo is required", i)
228		}
229		if err := relPath(r.Path); err != nil {
230			bad("repos[%d].path: %v", i, err)
231		} else if c := filepath.Clean(r.Path); c == "." {
232			bad("repos[%d].path: must not be the main checkout", i)
233		} else if paths[c] {
234			bad("repos[%d].path: %q used twice", i, r.Path)
235		} else {
236			paths[c] = true
237		}
238		if r.Env != "" && !envName.MatchString(r.Env) {
239			bad("repos[%d].env: %q is not a valid variable name", i, r.Env)
240		}
241	}
242	for k := range m.Environment {
243		if !envName.MatchString(k) {
244			bad("environment: %q is not a valid variable name", k)
245		}
246	}
247	for i, c := range m.When {
248		if len(c.Event) == 0 {
249			bad("when[%d]: event is required", i)
250		}
251		for _, e := range c.Event {
252			if !slices.Contains([]string{"push", "manual", "pull_request", "schedule"}, e) {
253				bad("when[%d]: unknown event %q", i, e)
254			}
255		}
256		for _, p := range slices.Concat(c.Branch, c.Tag, c.Paths) {
257			if !validGlob(p) {
258				bad("when[%d]: invalid glob %q", i, p)
259			}
260		}
261	}
262	return errors.Join(errs...)
263}
264
265// relPath checks that p is a relative path. It may climb out of the main
266// checkout (../servers): extra repos sit next to it.
267func relPath(p string) error {
268	if p == "" {
269		return fmt.Errorf("must not be empty")
270	}
271	if filepath.IsAbs(p) {
272		return fmt.Errorf("%q must be relative to the main checkout", p)
273	}
274	return nil
275}
276
277// IsStep reports whether action is on the happy path, and its index.
278func (m *Manifest) IsStep(action string) (int, bool) {
279	i := slices.Index(m.Steps, action)
280	return i, i >= 0
281}
282
283// Label returns an action's display label.
284func (m *Manifest) Label(action string) string {
285	if a, ok := m.Actions[action]; ok && a.Label != "" {
286		return a.Label
287	}
288	return action
289}
290
291// GateTimeout returns the timeout of an action's declared gate.
292func (m *Manifest) GateTimeout(action string) time.Duration {
293	a := m.Actions[action]
294	if a.Gate == nil || a.Gate.Timeout == "" {
295		return DefaultGateTimeout
296	}
297	d, _ := time.ParseDuration(a.Gate.Timeout)
298	return d
299}