This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-workflow / src / lib.rs
18 kB 593 lines
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}