This repository has no description
1use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
2use knot_types::{ChangedFiles, Listing, ParseError, RefName};
3use serde::Deserialize;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct WorkflowName(String);
7
8impl WorkflowName {
9 pub fn new(value: impl Into<String>) -> Result<Self, ParseError> {
10 let value = value.into();
11 let valid =
12 !value.is_empty() && !value.contains('/') && !value.chars().any(char::is_control);
13 match valid {
14 true => Ok(Self(value)),
15 false => Err(ParseError::Invalid {
16 kind: "workflow name",
17 value,
18 }),
19 }
20 }
21
22 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25}
26
27fn engine_is_named(engine: &str) -> Result<(), ParseError> {
28 match !engine.chars().any(|c| c.is_whitespace() || c.is_control()) {
29 true => Ok(()),
30 false => Err(ParseError::Invalid {
31 kind: "engine reference",
32 value: engine.to_string(),
33 }),
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum CloneDepth {
39 Full,
40 Limited,
41}
42
43impl CloneDepth {
44 fn parse(depth: i64) -> Result<Self, ParseError> {
45 match u32::try_from(depth) {
46 Ok(0) => Ok(Self::Full),
47 Ok(_) => Ok(Self::Limited),
48 Err(_) => Err(ParseError::Invalid {
49 kind: "clone depth",
50 value: depth.to_string(),
51 }),
52 }
53 }
54}
55
56pub struct RawWorkflow {
57 pub name: WorkflowName,
58 pub contents: Vec<u8>,
59}
60
61pub enum Trigger {
62 Push { ref_name: RefName },
63}
64
65impl Trigger {
66 fn kind(&self) -> &'static str {
67 match self {
68 Trigger::Push { .. } => "push",
69 }
70 }
71}
72
73#[derive(Debug, Default, Clone, PartialEq, Eq)]
74pub struct Diagnostics {
75 pub errors: Vec<String>,
76 pub warnings: Vec<String>,
77}
78
79impl Diagnostics {
80 fn error(&mut self, path: &str, message: impl AsRef<str>) {
81 self.errors
82 .push(format!("error: {path}: {}", message.as_ref()));
83 }
84
85 fn warning(&mut self, path: &str, kind: &str, reason: &str) {
86 self.warnings
87 .push(format!("warning: {path}: {kind}: {reason}"));
88 }
89
90 fn combine(mut self, other: Diagnostics) -> Diagnostics {
91 self.errors.extend(other.errors);
92 self.warnings.extend(other.warnings);
93 self
94 }
95
96 pub fn is_empty(&self) -> bool {
97 self.errors.is_empty() && self.warnings.is_empty()
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
102pub enum PathMatch {
103 Assumed,
104 Listed,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct CompiledWorkflow {
109 pub name: WorkflowName,
110 pub paths: PathMatch,
111}
112
113pub struct Compiled {
114 pub workflows: Vec<CompiledWorkflow>,
115 pub diagnostics: Diagnostics,
116}
117
118impl Compiled {
119 pub fn any_listed_match(&self) -> bool {
120 self.workflows
121 .iter()
122 .any(|workflow| workflow.paths == PathMatch::Listed)
123 }
124}
125
126pub fn compile(raw: &[RawWorkflow], trigger: &Trigger, changed: &ChangedFiles) -> Compiled {
127 let (workflows, diagnostics) = raw
128 .iter()
129 .map(|workflow| compile_one(workflow, trigger, changed))
130 .fold(
131 (Vec::new(), Diagnostics::default()),
132 |(mut workflows, diagnostics), (compiled, diag)| {
133 workflows.extend(compiled);
134 (workflows, diagnostics.combine(diag))
135 },
136 );
137 Compiled {
138 workflows,
139 diagnostics,
140 }
141}
142
143fn compile_one(
144 raw: &RawWorkflow,
145 trigger: &Trigger,
146 changed: &ChangedFiles,
147) -> (Option<CompiledWorkflow>, Diagnostics) {
148 let mut diag = Diagnostics::default();
149 let parsed = match parse(&raw.contents) {
150 Ok(parsed) => parsed,
151 Err(error) => {
152 diag.error(raw.name.as_str(), error.to_string());
153 return (None, diag);
154 }
155 };
156 let matched = match workflow_matches(&parsed.when, trigger, changed) {
157 Ok(matched) => matched,
158 Err(error) => {
159 diag.error(
160 raw.name.as_str(),
161 format!("failed to execute workflow: {error}"),
162 );
163 return (None, diag);
164 }
165 };
166 let Some(paths) = matched else {
167 diag.warning(
168 raw.name.as_str(),
169 "workflow skipped",
170 &format!("didn't match trigger {}", trigger.kind()),
171 );
172 return (None, diag);
173 };
174 let depth = match CloneDepth::parse(parsed.clone.depth) {
175 Ok(depth) => depth,
176 Err(error) => {
177 diag.error(raw.name.as_str(), error.to_string());
178 return (None, diag);
179 }
180 };
181 analyze_clone(&parsed.clone, depth, raw.name.as_str(), &mut diag);
182 if parsed.engine.is_empty() {
183 diag.error(raw.name.as_str(), "missing engine");
184 return (None, diag);
185 }
186 match engine_is_named(&parsed.engine) {
187 Ok(()) => (
188 Some(CompiledWorkflow {
189 name: raw.name.clone(),
190 paths,
191 }),
192 diag,
193 ),
194 Err(error) => {
195 diag.error(raw.name.as_str(), error.to_string());
196 (None, diag)
197 }
198 }
199}
200
201fn analyze_clone(clone: &CloneOpts, depth: CloneDepth, path: &str, diag: &mut Diagnostics) {
202 if !clone.skip {
203 return;
204 }
205 [
206 ("tags", clone.tags.is_some()),
207 ("submodules", clone.submodules.is_some()),
208 ("depth", depth == CloneDepth::Limited),
209 ]
210 .into_iter()
211 .filter(|(_, set)| *set)
212 .for_each(|(key, _)| {
213 diag.warning(
214 path,
215 "invalid configuration",
216 &format!("`clone.{key}` has no effect with `clone.skip`"),
217 );
218 });
219}
220
221fn workflow_matches(
222 when: &[Constraint],
223 trigger: &Trigger,
224 changed: &ChangedFiles,
225) -> Result<Option<PathMatch>, String> {
226 if when.is_empty() {
227 return Ok(Some(PathMatch::Listed));
228 }
229 when.iter()
230 .map(|constraint| constraint_matches(constraint, trigger, changed))
231 .collect::<Result<Vec<Option<PathMatch>>, String>>()
232 .map(|results| results.into_iter().flatten().max())
233}
234
235fn constraint_matches(
236 constraint: &Constraint,
237 trigger: &Trigger,
238 changed: &ChangedFiles,
239) -> Result<Option<PathMatch>, String> {
240 match trigger {
241 Trigger::Push { ref_name } => {
242 let event = constraint.event.0.iter().any(|kind| kind == "push");
243 let reference = match ref_kind(ref_name.as_str()) {
244 Some((RefKind::Branch, short)) => glob_set(&constraint.branch.0)?.is_match(short),
245 Some((RefKind::Tag, short)) => glob_set(&constraint.tag.0)?.is_match(short),
246 None => false,
247 };
248 let globs = glob_set(&constraint.paths.0)?;
249 let listed = changed
250 .paths()
251 .iter()
252 .any(|path| globs.is_match(path.as_str()));
253 // We aren't the one deciding whether this workflow runs, remember,
254 // spindles are, and spindles will decide from the
255 // atproto record we will emit.
256 let paths = match (constraint.paths.0.is_empty(), listed, changed.listing()) {
257 (true, _, _) | (false, true, _) => Some(PathMatch::Listed),
258 (false, false, Listing::Truncated) => Some(PathMatch::Assumed),
259 (false, false, Listing::Complete) => None,
260 };
261 Ok(paths.filter(|_| event && reference))
262 }
263 }
264}
265
266enum RefKind {
267 Branch,
268 Tag,
269}
270
271fn ref_kind(reference: &str) -> Option<(RefKind, &str)> {
272 reference
273 .strip_prefix("refs/heads/")
274 .map(|short| (RefKind::Branch, short))
275 .or_else(|| {
276 reference
277 .strip_prefix("refs/tags/")
278 .map(|short| (RefKind::Tag, short))
279 })
280}
281
282fn glob_set(patterns: &[String]) -> Result<GlobSet, String> {
283 patterns
284 .iter()
285 .try_fold(GlobSetBuilder::new(), |mut builder, pattern| {
286 GlobBuilder::new(pattern)
287 .literal_separator(true)
288 .build()
289 .map(|glob| {
290 builder.add(glob);
291 builder
292 })
293 .map_err(|error| error.to_string())
294 })
295 .and_then(|builder| builder.build().map_err(|error| error.to_string()))
296}
297
298fn parse(contents: &[u8]) -> Result<WorkflowFile, serde_norway::Error> {
299 serde_norway::from_slice(contents)
300}
301
302#[derive(Debug, Default, Deserialize)]
303struct WorkflowFile {
304 #[serde(default)]
305 engine: String,
306 #[serde(default)]
307 when: Vec<Constraint>,
308 #[serde(default)]
309 clone: CloneOpts,
310}
311
312#[derive(Debug, Default, Deserialize)]
313struct Constraint {
314 #[serde(default)]
315 event: StringList,
316 #[serde(default)]
317 branch: StringList,
318 #[serde(default)]
319 tag: StringList,
320 #[serde(default)]
321 paths: StringList,
322}
323
324#[derive(Debug, Default, Deserialize)]
325struct CloneOpts {
326 #[serde(default)]
327 skip: bool,
328 #[serde(default)]
329 depth: i64,
330 #[serde(default)]
331 submodules: Option<bool>,
332 #[serde(default)]
333 tags: Option<bool>,
334}
335
336#[derive(Debug, Default)]
337struct StringList(Vec<String>);
338
339impl<'de> Deserialize<'de> for StringList {
340 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
341 where
342 D: serde::Deserializer<'de>,
343 {
344 #[derive(Deserialize)]
345 #[serde(untagged)]
346 enum OneOrMany {
347 One(String),
348 Many(Vec<String>),
349 }
350 Ok(match OneOrMany::deserialize(deserializer)? {
351 OneOrMany::One(value) => StringList(vec![value]),
352 OneOrMany::Many(values) => StringList(values),
353 })
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn raw(name: &str, contents: &str) -> RawWorkflow {
362 RawWorkflow {
363 name: WorkflowName::new(name).unwrap(),
364 contents: contents.as_bytes().to_vec(),
365 }
366 }
367
368 fn changed(values: &[&str]) -> ChangedFiles {
369 let mut budget = knot_types::ChangedFilesBudget::new();
370 let _ = values
371 .iter()
372 .try_for_each(|value| budget.admit(knot_types::RepoPath::new(*value).unwrap()));
373 budget.finish()
374 }
375
376 fn push(reference: &str) -> Trigger {
377 Trigger::Push {
378 ref_name: RefName::new(reference).unwrap(),
379 }
380 }
381
382 #[test]
383 fn workflow_name_round_trips_through_as_str() {
384 let name = WorkflowName::new("test.yml").unwrap();
385 assert_eq!(name.as_str(), "test.yml");
386 assert_eq!(name, WorkflowName::new("test.yml".to_string()).unwrap());
387 assert_ne!(name, WorkflowName::new("ci.yml").unwrap());
388 }
389
390 #[test]
391 fn a_matching_branch_push_compiles_the_workflow() {
392 let workflows = [raw(
393 "test.yml",
394 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: [main]\n",
395 )];
396 let compiled = compile(&workflows, &push("refs/heads/main"), &ChangedFiles::none());
397 assert_eq!(
398 compiled
399 .workflows
400 .iter()
401 .map(|workflow| workflow.name.as_str())
402 .collect::<Vec<&str>>(),
403 vec!["test.yml"]
404 );
405 assert!(
406 compiled.any_listed_match(),
407 "a workflow with no paths constraint never depends on the listing"
408 );
409 assert!(
410 compiled.diagnostics.is_empty(),
411 "{:?}",
412 compiled.diagnostics
413 );
414 }
415
416 #[test]
417 fn a_paths_constraint_matches_changed_files_and_never_their_parent_directories() {
418 let changed = changed(&["src/deep/main.rs"]);
419 let cases: &[(&str, usize)] = &[
420 ("", 1),
421 ("\n paths: ['src/**']", 1),
422 ("\n paths: ['**/main.rs']", 1),
423 ("\n paths: ['docs/**']", 0),
424 ("\n paths: ['src']", 0),
425 ("\n paths: ['*']", 0),
426 ("\n paths: ['docs/**', 'src/**']", 1),
427 ];
428 cases.iter().for_each(|(constraint, count)| {
429 let yaml = format!(
430 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']{constraint}\n"
431 );
432 let compiled = compile(&[raw("ci.yml", &yaml)], &push("refs/heads/main"), &changed);
433 assert_eq!(compiled.workflows.len(), *count, "{yaml}");
434 });
435
436 let unmatched_branch = compile(
437 &[raw(
438 "ci.yml",
439 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: [release]\n paths: ['[']\n",
440 )],
441 &push("refs/heads/main"),
442 &changed,
443 );
444 assert!(
445 !unmatched_branch.diagnostics.errors.is_empty(),
446 "compile reports a malformed paths glob even when the branch already decided the match: {:?}",
447 unmatched_branch.diagnostics
448 );
449 }
450
451 #[test]
452 fn a_truncated_listing_assumes_a_paths_match_and_only_a_listed_hit_promises_the_run() {
453 let yaml = "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n paths: ['src/**']\n";
454 assert_eq!(
455 compile(
456 &[raw("ci.yml", yaml)],
457 &push("refs/heads/main"),
458 &changed(&["docs/only.md"])
459 )
460 .workflows
461 .len(),
462 0,
463 "a complete listing that misses the globs skips the workflow"
464 );
465
466 let assumed = compile(
467 &[raw("ci.yml", yaml)],
468 &push("refs/heads/main"),
469 &ChangedFiles::unknown(),
470 );
471 assert_eq!(
472 assumed.workflows.len(),
473 1,
474 "a listing the record couldn't hold rules no glob out"
475 );
476 assert_eq!(assumed.workflows[0].paths, PathMatch::Assumed);
477 assert!(
478 !assumed.any_listed_match(),
479 "spindle reads the same truncated listing and skips this run"
480 );
481
482 let mut budget = knot_types::ChangedFilesBudget::new();
483 let _ = budget.admit(knot_types::RepoPath::new("src/deep/main.rs").unwrap());
484 let _ = budget.truncate();
485 let listed = compile(
486 &[raw("ci.yml", yaml)],
487 &push("refs/heads/main"),
488 &budget.finish(),
489 );
490 assert_eq!(listed.workflows[0].paths, PathMatch::Listed);
491 assert!(
492 listed.any_listed_match(),
493 "spindle sees the same listed path and runs this workflow"
494 );
495
496 let strongest = compile(
497 &[raw(
498 "ci.yml",
499 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n paths: ['never/**']\n - event: push\n branch: ['**']\n",
500 )],
501 &push("refs/heads/main"),
502 &ChangedFiles::unknown(),
503 );
504 assert_eq!(
505 strongest.workflows[0].paths,
506 PathMatch::Listed,
507 "an assumed constraint never weakens an unconstrained one"
508 );
509 }
510
511 #[test]
512 fn compile_cases() {
513 let tag = "engine: nixery.dev/x\nwhen:\n - event: push\n tag: ['v*']\n";
514 let single_star =
515 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['feature/*']\n";
516 let cases: &[(&str, &str, usize, Option<&str>)] = &[
517 (
518 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: [dev]\n",
519 "refs/heads/main",
520 0,
521 Some("workflow skipped"),
522 ),
523 ("engine: nixery.dev/x\n", "refs/heads/anything", 1, None),
524 (
525 "when:\n - event: push\n branch: ['*']\n",
526 "refs/heads/main",
527 0,
528 Some("missing engine"),
529 ),
530 (
531 "engine: nixery.dev/x\nclone:\n skip: true\n submodules: true\n",
532 "refs/heads/main",
533 1,
534 Some("`clone.submodules` has no effect with `clone.skip`"),
535 ),
536 (
537 "engine: nixery.dev/x\nclone:\n skip: true\n submodules: false\n",
538 "refs/heads/main",
539 1,
540 Some("`clone.submodules` has no effect with `clone.skip`"),
541 ),
542 (
543 "engine: nixery.dev/x\nclone:\n skip: true\n tags: true\n",
544 "refs/heads/main",
545 1,
546 Some("`clone.tags` has no effect with `clone.skip`"),
547 ),
548 (
549 "engine: nixery.dev/x\nclone:\n skip: true\n",
550 "refs/heads/main",
551 1,
552 None,
553 ),
554 (
555 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: '**'\n",
556 "refs/heads/feature/x",
557 1,
558 None,
559 ),
560 (tag, "refs/tags/v1.0", 1, None),
561 (tag, "refs/heads/v1.0", 0, None),
562 (single_star, "refs/heads/feature/x", 1, None),
563 (single_star, "refs/heads/feature/x/y", 0, None),
564 ];
565 cases.iter().for_each(|(yaml, reference, count, diag)| {
566 let compiled = compile(
567 &[raw("ci.yml", yaml)],
568 &push(reference),
569 &ChangedFiles::none(),
570 );
571 assert_eq!(compiled.workflows.len(), *count, "{yaml}");
572 let messages: Vec<&String> = compiled
573 .diagnostics
574 .errors
575 .iter()
576 .chain(compiled.diagnostics.warnings.iter())
577 .collect();
578 match diag {
579 Some(sub) => assert!(
580 messages.iter().any(|message| message.contains(sub)),
581 "{sub}: {messages:?}"
582 ),
583 None => assert!(
584 compiled.diagnostics.errors.is_empty()
585 && !messages
586 .iter()
587 .any(|message| message.contains("invalid configuration")),
588 "{yaml}: {messages:?}"
589 ),
590 }
591 });
592 }
593}