971c37c
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<String>,
pub on: WorkflowTriggers,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency: Option<Concurrency>,
pub jobs: BTreeMap<String, Job>,
}
#[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<PushTrigger>,
#[serde(
default,
rename = "pull_request",
skip_serializing_if = "Option::is_none"
)]
pub pull_request: Option<PullRequestTrigger>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_dispatch: Option<WorkflowDispatchTrigger>,
}
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<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct PullRequestTrigger {
#[serde(default)]
pub branches: Vec<String>,
#[serde(default)]
pub types: Vec<PullRequestAction>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkflowDispatchTrigger {
#[serde(default)]
pub inputs: BTreeMap<String, String>,
}
#[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<String>,
#[serde(default, rename = "if", skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
#[serde(default)]
pub needs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default)]
pub permissions: MarkedPermissions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
#[serde(
default,
rename = "timeout-minutes",
skip_serializing_if = "Option::is_none"
)]
pub timeout_minutes: Option<u64>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub artifacts: Vec<ArtifactSpec>,
pub steps: Vec<Step>,
}
fn default_runs_on() -> Vec<String> {
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<PermissionLevel>,
}
#[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<String>,
pub run: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(
default,
rename = "working-directory",
skip_serializing_if = "Option::is_none"
)]
pub working_directory: Option<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(
default,
rename = "timeout-minutes",
skip_serializing_if = "Option::is_none"
)]
pub timeout_minutes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct WorkflowParseError(pub String);
pub fn parse_workflow_str(source: &str) -> Result<WorkflowDefinition, WorkflowParseError> {
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<String, Job>,
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<String>,
pub triggers: TriggerSummary,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct TriggerSummary {
pub push_branches: Vec<String>,
pub push_tags: Vec<String>,
pub pull_request_branches: Vec<String>,
pub pull_request_types: Vec<PullRequestAction>,
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<GlobSet> {
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,
}
}