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}