use std::collections::BTreeMap; use globset::{Glob, GlobSet, GlobSetBuilder}; use serde::{Deserialize, Serialize}; pub const WORKFLOW_DIR: &str = ".workflows"; pub const MAX_JOBS: usize = 50; pub const MAX_STEPS: usize = 50; pub const DEFAULT_JOB_TIMEOUT_MINUTES: u64 = 60; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct WorkflowDefinition { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, pub on: WorkflowTriggers, #[serde(default)] pub env: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub concurrency: Option, pub jobs: BTreeMap, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct WorkflowTriggers { #[serde(default, skip_serializing_if = "Option::is_none")] pub push: Option, #[serde( default, rename = "pull_request", skip_serializing_if = "Option::is_none" )] pub pull_request: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_dispatch: Option, } impl WorkflowTriggers { #[must_use] pub fn is_empty(&self) -> bool { self.push.is_none() && self.pull_request.is_none() && self.workflow_dispatch.is_none() } } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct PushTrigger { #[serde(default)] pub branches: Vec, #[serde(default)] pub tags: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct PullRequestTrigger { #[serde(default)] pub branches: Vec, #[serde(default)] pub types: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct WorkflowDispatchTrigger { #[serde(default)] pub inputs: BTreeMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum PullRequestAction { Opened, Synchronize, Reopened, Merged, Closed, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct Concurrency { pub group: String, #[serde(default)] pub cancel_in_progress: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct Job { #[serde(default = "default_runs_on", rename = "runs-on")] pub runs_on: Vec, #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")] pub condition: Option, #[serde(default)] pub needs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub environment: Option, #[serde(default)] pub permissions: MarkedPermissions, #[serde(default, skip_serializing_if = "Option::is_none")] pub container: Option, #[serde( default, rename = "timeout-minutes", skip_serializing_if = "Option::is_none" )] pub timeout_minutes: Option, #[serde(default)] pub env: BTreeMap, #[serde(default)] pub artifacts: Vec, pub steps: Vec, } fn default_runs_on() -> Vec { vec!["self-hosted".to_string()] } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct MarkedPermissions { #[serde(default, skip_serializing_if = "Option::is_none")] pub contents: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum PermissionLevel { Read, Write, None, } impl MarkedPermissions { #[must_use] pub fn contents_write(&self) -> bool { matches!(self.contents, Some(PermissionLevel::Write)) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct ArtifactSpec { pub name: String, pub path: String, #[serde(default)] pub always: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(deny_unknown_fields)] pub struct Step { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, pub run: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub shell: Option, #[serde( default, rename = "working-directory", skip_serializing_if = "Option::is_none" )] pub working_directory: Option, #[serde(default)] pub env: BTreeMap, #[serde( default, rename = "timeout-minutes", skip_serializing_if = "Option::is_none" )] pub timeout_minutes: Option, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[error("{0}")] pub struct WorkflowParseError(pub String); pub fn parse_workflow_str(source: &str) -> Result { let workflow: WorkflowDefinition = serde_yaml_ng::from_str(source).map_err(|e| WorkflowParseError(e.to_string()))?; validate_workflow(&workflow)?; Ok(workflow) } pub fn validate_workflow(workflow: &WorkflowDefinition) -> Result<(), WorkflowParseError> { if workflow.on.is_empty() { return Err(WorkflowParseError( "`on` must declare at least one trigger".to_string(), )); } if workflow.jobs.is_empty() { return Err(WorkflowParseError( "`jobs` must declare at least one job".to_string(), )); } if workflow.jobs.len() > MAX_JOBS { return Err(WorkflowParseError(format!( "too many jobs: {} (max {MAX_JOBS})", workflow.jobs.len() ))); } for (name, job) in &workflow.jobs { if name.trim().is_empty() || name.chars().count() > 128 { return Err(WorkflowParseError( "job name must be 1-128 characters".to_string(), )); } if job.steps.is_empty() { return Err(WorkflowParseError(format!( "job `{name}` must declare at least one step" ))); } if job.steps.len() > MAX_STEPS { return Err(WorkflowParseError(format!( "job `{name}` has too many steps: {} (max {MAX_STEPS})", job.steps.len() ))); } if job.runs_on.is_empty() || job.runs_on.iter().any(|l| l.trim().is_empty()) { return Err(WorkflowParseError(format!( "job `{name}` runs-on label must not be empty" ))); } if let Some(container) = &job.container && container.trim().is_empty() { return Err(WorkflowParseError(format!( "job `{name}` container must not be empty when set" ))); } for need in &job.needs { if !workflow.jobs.contains_key(need) { return Err(WorkflowParseError(format!( "job `{name}` needs unknown job `{need}`" ))); } if need == name { return Err(WorkflowParseError(format!( "job `{name}` must not depend on itself" ))); } } for step in &job.steps { if step.run.trim().is_empty() { return Err(WorkflowParseError(format!( "job `{name}` has an empty run step" ))); } } } ensure_acyclic(workflow)?; Ok(()) } #[derive(Clone, Copy, PartialEq, Eq)] enum VisitMark { Visiting, Done, } fn visit_job<'a>( nodes: &'a BTreeMap, name: &'a str, marks: &mut BTreeMap<&'a str, VisitMark>, ) -> Result<(), WorkflowParseError> { match marks.get(name) { Some(VisitMark::Done) => return Ok(()), Some(VisitMark::Visiting) => { return Err(WorkflowParseError(format!( "dependency cycle detected at job `{name}`" ))); } None => {} } marks.insert(name, VisitMark::Visiting); let job = nodes .get(name) .ok_or_else(|| WorkflowParseError(format!("unknown job `{name}`")))?; for need in &job.needs { let key = nodes .get_key_value(need.as_str()) .map(|(key, _)| key.as_str()) .ok_or_else(|| WorkflowParseError(format!("unknown job `{need}`")))?; visit_job(nodes, key, marks)?; } marks.insert(name, VisitMark::Done); Ok(()) } fn ensure_acyclic(workflow: &WorkflowDefinition) -> Result<(), WorkflowParseError> { let mut marks: BTreeMap<&str, VisitMark> = BTreeMap::new(); for name in workflow.jobs.keys() { visit_job(&workflow.jobs, name.as_str(), &mut marks)?; } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkflowSummary { pub file: String, pub name: String, pub valid: bool, pub errors: Vec, pub triggers: TriggerSummary, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct TriggerSummary { pub push_branches: Vec, pub push_tags: Vec, pub pull_request_branches: Vec, pub pull_request_types: Vec, pub workflow_dispatch: bool, } impl TriggerSummary { #[must_use] pub fn from_triggers(triggers: &WorkflowTriggers) -> Self { Self { push_branches: triggers .push .as_ref() .map(|p| p.branches.clone()) .unwrap_or_default(), push_tags: triggers .push .as_ref() .map(|p| p.tags.clone()) .unwrap_or_default(), pull_request_branches: triggers .pull_request .as_ref() .map(|p| p.branches.clone()) .unwrap_or_default(), pull_request_types: triggers .pull_request .as_ref() .map(|p| p.types.clone()) .unwrap_or_default(), workflow_dispatch: triggers.workflow_dispatch.is_some(), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefKind { Branch, Tag, } pub fn build_glob_set(patterns: &[String]) -> Option { if patterns.is_empty() { return None; } let mut builder = GlobSetBuilder::new(); let mut any = false; for pattern in patterns { if let Ok(glob) = Glob::new(pattern) { builder.add(glob); any = true; } } if !any { return None; } builder.build().ok() } #[must_use] pub fn matches_patterns(patterns: &[String], value: &str) -> bool { match build_glob_set(patterns) { Some(set) => set.is_match(value), None => false, } }