From 8addb8eb2c153597186188970c9c890dbba8f352 Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Mon, 31 Aug 2026 09:53:38 +0200 Subject: [PATCH] feat: Expose workflow trigger preflight --- diff --git a/VERSION b/VERSION --- a/VERSION +++ b/VERSION @@ -1,1 +1,1 @@ -0.3.0 +0.4.0 diff --git a/crates/workflow-github-actions/tests/workflow_triggers.rs b/crates/workflow-github-actions/tests/workflow_triggers.rs --- a/crates/workflow-github-actions/tests/workflow_triggers.rs +++ b/crates/workflow-github-actions/tests/workflow_triggers.rs @@ -1,182 +1,217 @@ -//! What a workflow says fires it. `on:` has three shapes in this dialect and -//! each event has its own, so reading one the way another is written refuses a -//! workflow that is spelled exactly as the dialect says to spell it. - -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use syncode_workflow_github_actions::workflow::{Workflow, parse}; - -#[test] -fn reads_the_events_a_workflow_declares() { - let node = parse( - r#" -on: - push: - branches: [main, "release/**"] - paths-ignore: ["**.md"] -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo one -"#, - ) - .unwrap_or_else(|error| panic!("parse: {error}")); - let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); - - let touched = syncode_workflow::Event::new( - syncode_workflow::EventKind::Push, - "release/0.3".to_owned(), - vec!["crates/workflow/src/lib.rs".to_owned()], - ); - let docs_only = syncode_workflow::Event::new( - syncode_workflow::EventKind::Push, - "main".to_owned(), - vec!["README.md".to_owned()], - ); - let other_branch = syncode_workflow::Event::new( - syncode_workflow::EventKind::Push, - "wip/x".to_owned(), - vec!["src/main.rs".to_owned()], - ); - - assert!(workflow.triggers.fire_on(&touched)); - assert!(!workflow.triggers.fire_on(&docs_only)); - assert!(!workflow.triggers.fire_on(&other_branch)); -} - -#[test] -fn reads_a_bare_list_of_events() { - let node = parse( - r#" -on: [push, workflow_dispatch] -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo one -"#, - ) - .unwrap_or_else(|error| panic!("parse: {error}")); - let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); - - assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new( - syncode_workflow::EventKind::Manual, - "anything".to_owned(), - Vec::new(), - ))); -} - -#[test] -fn refuses_an_event_this_control_plane_does_not_act_on() { - let node = parse( - r#" -on: [deployment_status] -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo one -"#, - ) - .unwrap_or_else(|error| panic!("parse: {error}")); - let error = Workflow::from_node(&node) - .expect_err("an event nobody handles must not read as triggering nothing"); - - assert!( - error.to_string().contains("cannot act on"), - "unexpected error: {error}" - ); -} - -#[test] -fn a_callable_workflow_declares_itself_without_declaring_an_event() { - let node = parse( - r#" -on: - workflow_call: - inputs: - target: - type: string -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo one -"#, - ) - .unwrap_or_else(|error| panic!("parse: {error}")); - let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); - - assert!( - workflow.triggers.is_empty(), - "being callable is not an event that fires a run" - ); - assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new( - syncode_workflow::EventKind::Push, - "main".to_owned(), - vec!["src/main.rs".to_owned()], - ))); -} - -#[test] -fn a_schedule_is_read_as_the_sequence_of_cron_entries_it_is_written_as() { - let node = parse( - r#" -on: - schedule: - - cron: "17 3 * * *" - push: - branches: [main] -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo one -"#, - ) - .unwrap_or_else(|error| panic!("parse: {error}")); - let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); - - assert!( - !workflow.triggers.is_empty(), - "a schedule alongside a push is a trigger, not a refusal" - ); - assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new( - syncode_workflow::EventKind::Schedule, - "main".to_owned(), - Vec::new(), - ))); - assert!( - workflow.triggers.fire_on(&syncode_workflow::Event::new( - syncode_workflow::EventKind::Push, - "main".to_owned(), - vec!["src/main.rs".to_owned()], - )), - "reading the schedule must not cost the push its filters" - ); -} - -#[test] -fn a_schedule_written_as_a_mapping_is_refused_rather_than_guessed_at() { - let node = parse( - r#" -on: - schedule: - cron: "17 3 * * *" -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo one -"#, - ) - .unwrap_or_else(|error| panic!("parse: {error}")); - let error = Workflow::from_node(&node) - .expect_err("a schedule that is not a sequence is not a schedule"); - - assert!( - error.to_string().contains("$.on.schedule"), - "unexpected error: {error}" - ); -} +//! What a workflow says fires it. `on:` has three shapes in this dialect and +//! each event has its own, so reading one the way another is written refuses a +//! workflow that is spelled exactly as the dialect says to spell it. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use syncode_workflow_github_actions::workflow::{Workflow, parse}; + +#[test] +fn reads_the_events_a_workflow_declares() { + let node = parse( + r#" +on: + push: + branches: [main, "release/**"] + paths-ignore: ["**.md"] +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo one +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); + + let touched = syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Branch("release/0.3".to_owned()), + vec!["crates/workflow/src/lib.rs".to_owned()], + ); + let docs_only = syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Branch("main".to_owned()), + vec!["README.md".to_owned()], + ); + let other_branch = syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Branch("wip/x".to_owned()), + vec!["src/main.rs".to_owned()], + ); + + assert!(workflow.triggers.fire_on(&touched)); + assert!(!workflow.triggers.fire_on(&docs_only)); + assert!(!workflow.triggers.fire_on(&other_branch)); +} + +#[test] +fn reads_a_bare_list_of_events() { + let node = parse( + r#" +on: [push, workflow_dispatch] +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo one +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); + + assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new( + syncode_workflow::EventKind::Manual, + syncode_workflow::GitReference::Branch("anything".to_owned()), + Vec::new(), + ))); +} + +#[test] +fn refuses_an_event_this_control_plane_does_not_act_on() { + let node = parse( + r#" +on: [deployment_status] +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo one +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + let error = Workflow::from_node(&node) + .expect_err("an event nobody handles must not read as triggering nothing"); + + assert!( + error.to_string().contains("cannot act on"), + "unexpected error: {error}" + ); +} + +#[test] +fn a_callable_workflow_declares_itself_without_declaring_an_event() { + let node = parse( + r#" +on: + workflow_call: + inputs: + target: + type: string +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo one +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); + + assert!( + workflow.triggers.is_empty(), + "being callable is not an event that fires a run" + ); + assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Branch("main".to_owned()), + vec!["src/main.rs".to_owned()], + ))); +} + +#[test] +fn a_schedule_is_read_as_the_sequence_of_cron_entries_it_is_written_as() { + let node = parse( + r#" +on: + schedule: + - cron: "17 3 * * *" + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo one +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); + + assert!( + !workflow.triggers.is_empty(), + "a schedule alongside a push is a trigger, not a refusal" + ); + assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new( + syncode_workflow::EventKind::Schedule, + syncode_workflow::GitReference::Branch("main".to_owned()), + Vec::new(), + ))); + assert!( + workflow.triggers.fire_on(&syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Branch("main".to_owned()), + vec!["src/main.rs".to_owned()], + )), + "reading the schedule must not cost the push its filters" + ); +} + +#[test] +fn reads_tag_filters_without_treating_them_as_branches() { + let node = parse( + r#" +on: + push: + tags: ["v*"] + tags-ignore: ["v*-rc*"] +jobs: + release: + runs-on: ubuntu-latest + steps: + - run: echo release +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}")); + + assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Tag("v0.4.0".to_owned()), + Vec::new(), + ))); + assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Tag("v0.4.0-rc1".to_owned()), + Vec::new(), + ))); + assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new( + syncode_workflow::EventKind::Push, + syncode_workflow::GitReference::Branch("v0.4.0".to_owned()), + Vec::new(), + ))); +} + +#[test] +fn a_schedule_written_as_a_mapping_is_refused_rather_than_guessed_at() { + let node = parse( + r#" +on: + schedule: + cron: "17 3 * * *" +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo one +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + let error = Workflow::from_node(&node) + .expect_err("a schedule that is not a sequence is not a schedule"); + + assert!( + error.to_string().contains("$.on.schedule"), + "unexpected error: {error}" + ); +} diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -1,46 +1,48 @@ -mod action; -mod compiler; -mod container; -mod dynamic; -mod hir; -mod identifier; -mod plan; -mod repository; -mod reusable; -mod runtime; -mod source; -mod strategy; -mod template; -mod trigger; -mod value; - -pub use action::{ - ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, PlannedAction, - ResolvedAction, ResolvedLocalAction, ResolvedOciAction, ResolvedRemoteAction, Sha256Digest, -}; -pub use compiler::WorkflowCompiler; -pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition}; -pub use dynamic::{DynamicObject, Value}; -pub use hir::{ - ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts, - StepOperation, WorkflowHir, -}; -pub use identifier::{ - EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName, - ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName, -}; -pub use plan::{ - ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, PlanSchemaError, - PlanSchemaVersion, ShellStep, StepKind, StepPlan, StepPlanParts, VersionedPlan, expand, plans, -}; -pub use repository::{RepositoryUrl, Revision, SourceValueError}; -pub use reusable::{ReusableWorkflow, ReusableWorkflowError}; -pub use runtime::JavaScriptRuntime; -pub use source::{CompiledPlan, WorkflowDialect, WorkflowSource}; -pub use strategy::{JobStrategy, PositiveIntegerValue}; -pub use template::{Template, TemplateSegment}; -pub use trigger::{Event, EventKind, Filter, Pattern, Trigger, TriggerError, Triggers}; -pub use value::{ - BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding, - OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout, -}; +mod action; +mod compiler; +mod container; +mod dynamic; +mod hir; +mod identifier; +mod plan; +mod repository; +mod reusable; +mod runtime; +mod source; +mod strategy; +mod template; +mod trigger; +mod value; + +pub use action::{ + ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, PlannedAction, + ResolvedAction, ResolvedLocalAction, ResolvedOciAction, ResolvedRemoteAction, Sha256Digest, +}; +pub use compiler::WorkflowCompiler; +pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition}; +pub use dynamic::{DynamicObject, Value}; +pub use hir::{ + ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts, + StepOperation, WorkflowHir, +}; +pub use identifier::{ + EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName, + ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName, +}; +pub use plan::{ + ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, PlanSchemaError, + PlanSchemaVersion, ShellStep, StepKind, StepPlan, StepPlanParts, VersionedPlan, expand, plans, +}; +pub use repository::{RepositoryUrl, Revision, SourceValueError}; +pub use reusable::{ReusableWorkflow, ReusableWorkflowError}; +pub use runtime::JavaScriptRuntime; +pub use source::{CompiledPlan, WorkflowDialect, WorkflowSource}; +pub use strategy::{JobStrategy, PositiveIntegerValue}; +pub use template::{Template, TemplateSegment}; +pub use trigger::{ + Event, EventKind, Filter, GitReference, Pattern, Trigger, TriggerError, Triggers, +}; +pub use value::{ + BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding, + OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout, +}; diff --git a/crates/workflow/src/trigger.rs b/crates/workflow/src/trigger.rs --- a/crates/workflow/src/trigger.rs +++ b/crates/workflow/src/trigger.rs @@ -1,195 +1,238 @@ -use std::fmt; - -use thiserror::Error; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum EventKind { - Push, - PullRequest, - Manual, - Schedule, -} - -/// What happened, as much of it as deciding needs: which kind, on which branch, -/// and which paths it touched. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Event { - kind: EventKind, - branch: String, - changed_paths: Vec, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct Filter { - include: Vec, - exclude: Vec, -} - -/// A subset of the glob syntax the dialect allows: literals, `*` within one -/// segment, and `**` across segments. Anything else is refused rather than -/// matched by accident. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Pattern(String); - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Trigger { - kind: EventKind, - branches: Filter, - paths: Filter, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct Triggers(Vec); - -#[derive(Debug, Error)] -pub enum TriggerError { - #[error("pattern {0:?} uses glob syntax this compiler does not support")] - UnsupportedPattern(String), -} - -impl Event { - #[must_use] - pub fn new(kind: EventKind, branch: String, changed_paths: Vec) -> Self { - Self { - kind, - branch, - changed_paths, - } - } - - #[must_use] - pub const fn kind(&self) -> EventKind { - self.kind - } - - #[must_use] - pub fn branch(&self) -> &str { - &self.branch - } -} - -impl Pattern { - pub fn parse(value: &str) -> Result { - if value.contains(['?', '[', ']', '+', '!']) { - return Err(TriggerError::UnsupportedPattern(value.to_owned())); - } - Ok(Self(value.to_owned())) - } - - #[must_use] - pub fn matches(&self, candidate: &str) -> bool { - matches_from(&self.0, candidate) - } -} - -fn matches_from(pattern: &str, candidate: &str) -> bool { - match pattern.find('*') { - None => pattern == candidate, - Some(position) => { - let (literal, rest) = pattern.split_at(position); - if !candidate.starts_with(literal) { - return false; - } - let candidate = &candidate[literal.len()..]; - if let Some(rest) = rest.strip_prefix("**") { - (0..=candidate.len()).any(|skip| matches_from(rest, &candidate[skip..])) - } else { - let rest = &rest[1..]; - candidate - .char_indices() - .take_while(|(_, character)| *character != '/') - .map(|(index, character)| index + character.len_utf8()) - .chain(std::iter::once(0)) - .any(|skip| matches_from(rest, &candidate[skip..])) - } - } - } -} - -impl Filter { - #[must_use] - pub const fn new(include: Vec, exclude: Vec) -> Self { - Self { include, exclude } - } - - /// Nothing stated means everything passes. An exclusion always wins, which - /// is what makes `paths-ignore` mean what it says. - #[must_use] - pub fn admits(&self, candidate: &str) -> bool { - if self - .exclude - .iter() - .any(|pattern| pattern.matches(candidate)) - { - return false; - } - self.include.is_empty() - || self - .include - .iter() - .any(|pattern| pattern.matches(candidate)) - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.include.is_empty() && self.exclude.is_empty() - } -} - -impl Trigger { - #[must_use] - pub const fn new(kind: EventKind, branches: Filter, paths: Filter) -> Self { - Self { - kind, - branches, - paths, - } - } - - #[must_use] - pub fn fires_on(&self, event: &Event) -> bool { - if self.kind != event.kind { - return false; - } - if !self.branches.admits(&event.branch) { - return false; - } - // A path filter on an event that touched nothing has nothing to admit, - // so it does not fire. - self.paths.is_empty() - || event - .changed_paths - .iter() - .any(|path| self.paths.admits(path)) - } -} - -impl Triggers { - #[must_use] - pub const fn new(triggers: Vec) -> Self { - Self(triggers) - } - - /// A workflow with no `on:` is never triggered by an event. Saying nothing - /// is not saying everything. - #[must_use] - pub fn fire_on(&self, event: &Event) -> bool { - self.0.iter().any(|trigger| trigger.fires_on(event)) - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl fmt::Display for EventKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Push => formatter.write_str("push"), - Self::PullRequest => formatter.write_str("pull_request"), - Self::Manual => formatter.write_str("workflow_dispatch"), - Self::Schedule => formatter.write_str("schedule"), - } - } -} +use std::fmt; + +use thiserror::Error; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventKind { + Push, + PullRequest, + Manual, + Schedule, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitReference { + Branch(String), + Tag(String), +} + +/// What happened, as much of it as deciding needs: which kind, on which Git +/// reference, and which paths it touched. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Event { + kind: EventKind, + reference: GitReference, + changed_paths: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Filter { + include: Vec, + exclude: Vec, +} + +/// A subset of the glob syntax the dialect allows: literals, `*` within one +/// segment, and `**` across segments. Anything else is refused rather than +/// matched by accident. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Pattern(String); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Trigger { + kind: EventKind, + branches: Filter, + tags: Filter, + paths: Filter, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Triggers(Vec); + +#[derive(Debug, Error)] +pub enum TriggerError { + #[error("pattern {0:?} uses glob syntax this compiler does not support")] + UnsupportedPattern(String), +} + +impl Event { + #[must_use] + pub fn new(kind: EventKind, reference: GitReference, changed_paths: Vec) -> Self { + Self { + kind, + reference, + changed_paths, + } + } + + #[must_use] + pub const fn kind(&self) -> EventKind { + self.kind + } + + #[must_use] + pub const fn reference(&self) -> &GitReference { + &self.reference + } +} + +impl GitReference { + #[must_use] + pub fn name(&self) -> &str { + match self { + Self::Branch(name) | Self::Tag(name) => name, + } + } + + #[must_use] + pub fn full_name(&self) -> String { + match self { + Self::Branch(name) => format!("refs/heads/{name}"), + Self::Tag(name) => format!("refs/tags/{name}"), + } + } +} + +impl Pattern { + pub fn parse(value: &str) -> Result { + if value.contains(['?', '[', ']', '+', '!']) { + return Err(TriggerError::UnsupportedPattern(value.to_owned())); + } + Ok(Self(value.to_owned())) + } + + #[must_use] + pub fn matches(&self, candidate: &str) -> bool { + matches_from(&self.0, candidate) + } +} + +fn matches_from(pattern: &str, candidate: &str) -> bool { + match pattern.find('*') { + None => pattern == candidate, + Some(position) => { + let (literal, rest) = pattern.split_at(position); + if !candidate.starts_with(literal) { + return false; + } + let candidate = &candidate[literal.len()..]; + if let Some(rest) = rest.strip_prefix("**") { + (0..=candidate.len()).any(|skip| matches_from(rest, &candidate[skip..])) + } else { + let rest = &rest[1..]; + candidate + .char_indices() + .take_while(|(_, character)| *character != '/') + .map(|(index, character)| index + character.len_utf8()) + .chain(std::iter::once(0)) + .any(|skip| matches_from(rest, &candidate[skip..])) + } + } + } +} + +impl Filter { + #[must_use] + pub const fn new(include: Vec, exclude: Vec) -> Self { + Self { include, exclude } + } + + /// Nothing stated means everything passes. An exclusion always wins, which + /// is what makes `paths-ignore` mean what it says. + #[must_use] + pub fn admits(&self, candidate: &str) -> bool { + if self + .exclude + .iter() + .any(|pattern| pattern.matches(candidate)) + { + return false; + } + self.include.is_empty() + || self + .include + .iter() + .any(|pattern| pattern.matches(candidate)) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.include.is_empty() && self.exclude.is_empty() + } +} + +impl Trigger { + #[must_use] + pub const fn new(kind: EventKind, branches: Filter, tags: Filter, paths: Filter) -> Self { + Self { + kind, + branches, + tags, + paths, + } + } + + fn matches_kind_and_reference(&self, event: &Event) -> bool { + if self.kind != event.kind { + return false; + } + match event.reference() { + GitReference::Branch(branch) => { + (self.tags.is_empty() || !self.branches.is_empty()) && self.branches.admits(branch) + } + GitReference::Tag(tag) => { + (self.branches.is_empty() || !self.tags.is_empty()) && self.tags.admits(tag) + } + } + } + + #[must_use] + pub fn fires_on(&self, event: &Event) -> bool { + if !self.matches_kind_and_reference(event) { + return false; + } + // A path filter on an event that touched nothing has nothing to admit, + // so it does not fire. + self.paths.is_empty() + || event + .changed_paths + .iter() + .any(|path| self.paths.admits(path)) + } +} + +impl Triggers { + #[must_use] + pub const fn new(triggers: Vec) -> Self { + Self(triggers) + } + + /// A workflow with no `on:` is never triggered by an event. Saying nothing + /// is not saying everything. + #[must_use] + pub fn fire_on(&self, event: &Event) -> bool { + self.0.iter().any(|trigger| trigger.fires_on(event)) + } + + #[must_use] + pub fn may_fire_on(&self, event: &Event) -> bool { + self.0 + .iter() + .any(|trigger| trigger.matches_kind_and_reference(event)) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Display for EventKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Push => formatter.write_str("push"), + Self::PullRequest => formatter.write_str("pull_request"), + Self::Manual => formatter.write_str("workflow_dispatch"), + Self::Schedule => formatter.write_str("schedule"), + } + } +} diff --git a/crates/workflow/tests/trigger.rs b/crates/workflow/tests/trigger.rs --- a/crates/workflow/tests/trigger.rs +++ b/crates/workflow/tests/trigger.rs @@ -1,122 +1,189 @@ -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - -use syncode_workflow::{Event, EventKind, Filter, Pattern, Trigger, Triggers}; - -fn pattern(value: &str) -> Pattern { - Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}")) -} - -fn patterns(values: &[&str]) -> Vec { - values.iter().copied().map(pattern).collect() -} - -fn push(branch: &str, paths: &[&str]) -> Event { - Event::new( - EventKind::Push, - branch.to_owned(), - paths.iter().map(|path| (*path).to_owned()).collect(), - ) -} - -#[test] -fn a_workflow_that_says_nothing_is_never_triggered() { - assert!(!Triggers::default().fire_on(&push("main", &["src/main.rs"]))); -} - -#[test] -fn a_branch_filter_decides_which_pushes_count() { - let triggers = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::new(patterns(&["main", "release/*"]), Vec::new()), - Filter::default(), - )]); - - assert!(triggers.fire_on(&push("main", &[]))); - assert!(triggers.fire_on(&push("release/0.3", &[]))); - assert!(!triggers.fire_on(&push("feature/x", &[]))); - assert!( - !triggers.fire_on(&push("release/0.3/hotfix", &[])), - "a single star does not cross a slash" - ); -} - -#[test] -fn a_double_star_crosses_slashes() { - let triggers = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::new(patterns(&["release/**"]), Vec::new()), - Filter::default(), - )]); - - assert!(triggers.fire_on(&push("release/0.3", &[]))); - assert!(triggers.fire_on(&push("release/0.3/hotfix", &[]))); -} - -#[test] -fn an_exclusion_wins_over_an_inclusion() { - let triggers = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::new(patterns(&["**"]), patterns(&["wip/**"])), - Filter::default(), - )]); - - assert!(triggers.fire_on(&push("main", &[]))); - assert!(!triggers.fire_on(&push("wip/experiment", &[]))); -} - -#[test] -fn a_path_filter_needs_a_touched_path_to_admit() { - let triggers = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::default(), - Filter::new(patterns(&["crates/**"]), Vec::new()), - )]); - - assert!(triggers.fire_on(&push("main", &["crates/workflow/src/lib.rs"]))); - assert!(!triggers.fire_on(&push("main", &["README.md"]))); - assert!( - !triggers.fire_on(&push("main", &[])), - "an event that touched nothing has nothing for a path filter to admit" - ); -} - -#[test] -fn ignored_paths_do_not_trigger_on_their_own() { - let triggers = Triggers::new(vec![Trigger::new( - EventKind::Push, - Filter::default(), - Filter::new(Vec::new(), patterns(&["**.md"])), - )]); - - assert!(!triggers.fire_on(&push("main", &["README.md"]))); - assert!( - triggers.fire_on(&push("main", &["README.md", "src/main.rs"])), - "one path outside the ignore list is enough" - ); -} - -#[test] -fn an_event_of_another_kind_does_not_fire_the_trigger() { - let triggers = Triggers::new(vec![Trigger::new( - EventKind::PullRequest, - Filter::default(), - Filter::default(), - )]); - - assert!(!triggers.fire_on(&push("main", &["src/main.rs"]))); - assert!(triggers.fire_on(&Event::new( - EventKind::PullRequest, - "main".to_owned(), - vec!["src/main.rs".to_owned()], - ))); -} - -#[test] -fn glob_syntax_this_compiler_does_not_support_is_refused() { - let error = Pattern::parse("release/[0-9]*").expect_err("character classes are not supported"); - - assert!( - error.to_string().contains("does not support"), - "unexpected error: {error}" - ); -} +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use syncode_workflow::{Event, EventKind, Filter, GitReference, Pattern, Trigger, Triggers}; + +fn pattern(value: &str) -> Pattern { + Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}")) +} + +fn patterns(values: &[&str]) -> Vec { + values.iter().copied().map(pattern).collect() +} + +fn push(branch: &str, paths: &[&str]) -> Event { + Event::new( + EventKind::Push, + GitReference::Branch(branch.to_owned()), + paths.iter().map(|path| (*path).to_owned()).collect(), + ) +} + +#[test] +fn a_workflow_that_says_nothing_is_never_triggered() { + assert!(!Triggers::default().fire_on(&push("main", &["src/main.rs"]))); +} + +#[test] +fn a_branch_filter_decides_which_pushes_count() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["main", "release/*"]), Vec::new()), + Filter::default(), + Filter::default(), + )]); + + assert!(triggers.fire_on(&push("main", &[]))); + assert!(triggers.fire_on(&push("release/0.3", &[]))); + assert!(!triggers.fire_on(&push("feature/x", &[]))); + assert!( + !triggers.fire_on(&push("release/0.3/hotfix", &[])), + "a single star does not cross a slash" + ); +} + +#[test] +fn a_double_star_crosses_slashes() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["release/**"]), Vec::new()), + Filter::default(), + Filter::default(), + )]); + + assert!(triggers.fire_on(&push("release/0.3", &[]))); + assert!(triggers.fire_on(&push("release/0.3/hotfix", &[]))); +} + +#[test] +fn an_exclusion_wins_over_an_inclusion() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["**"]), patterns(&["wip/**"])), + Filter::default(), + Filter::default(), + )]); + + assert!(triggers.fire_on(&push("main", &[]))); + assert!(!triggers.fire_on(&push("wip/experiment", &[]))); +} + +#[test] +fn a_path_filter_needs_a_touched_path_to_admit() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::default(), + Filter::default(), + Filter::new(patterns(&["crates/**"]), Vec::new()), + )]); + + assert!(triggers.fire_on(&push("main", &["crates/workflow/src/lib.rs"]))); + assert!(!triggers.fire_on(&push("main", &["README.md"]))); + assert!( + !triggers.fire_on(&push("main", &[])), + "an event that touched nothing has nothing for a path filter to admit" + ); +} + +#[test] +fn kind_and_branch_can_reject_an_event_before_paths_are_loaded() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["main"]), Vec::new()), + Filter::default(), + Filter::new(patterns(&["crates/**"]), Vec::new()), + )]); + + assert!(!triggers.may_fire_on(&push("feature/x", &[]))); + assert!(triggers.may_fire_on(&push("main", &[]))); + assert!(!triggers.fire_on(&push("main", &[]))); +} + +#[test] +fn ignored_paths_do_not_trigger_on_their_own() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::default(), + Filter::default(), + Filter::new(Vec::new(), patterns(&["**.md"])), + )]); + + assert!(!triggers.fire_on(&push("main", &["README.md"]))); + assert!( + triggers.fire_on(&push("main", &["README.md", "src/main.rs"])), + "one path outside the ignore list is enough" + ); +} + +#[test] +fn an_event_of_another_kind_does_not_fire_the_trigger() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::PullRequest, + Filter::default(), + Filter::default(), + Filter::default(), + )]); + + assert!(!triggers.fire_on(&push("main", &["src/main.rs"]))); + assert!(triggers.fire_on(&Event::new( + EventKind::PullRequest, + GitReference::Branch("main".to_owned()), + vec!["src/main.rs".to_owned()], + ))); +} + +#[test] +fn branch_and_tag_filters_are_distinct() { + let triggers = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["main"]), Vec::new()), + Filter::new(patterns(&["v*"]), Vec::new()), + Filter::default(), + )]); + + assert!(triggers.fire_on(&push("main", &[]))); + assert!(!triggers.fire_on(&push("feature/x", &[]))); + assert!(triggers.fire_on(&Event::new( + EventKind::Push, + GitReference::Tag("v0.4.0".to_owned()), + Vec::new(), + ))); + assert!(!triggers.fire_on(&Event::new( + EventKind::Push, + GitReference::Tag("release-candidate".to_owned()), + Vec::new(), + ))); +} + +#[test] +fn a_filter_for_one_reference_kind_excludes_the_other_kind() { + let branches = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::new(patterns(&["main"]), Vec::new()), + Filter::default(), + Filter::default(), + )]); + let tags = Triggers::new(vec![Trigger::new( + EventKind::Push, + Filter::default(), + Filter::new(patterns(&["v*"]), Vec::new()), + Filter::default(), + )]); + + let tag = Event::new( + EventKind::Push, + GitReference::Tag("v0.4.0".to_owned()), + Vec::new(), + ); + assert!(!branches.fire_on(&tag)); + assert!(!tags.fire_on(&push("main", &[]))); +} + +#[test] +fn glob_syntax_this_compiler_does_not_support_is_refused() { + let error = Pattern::parse("release/[0-9]*").expect_err("character classes are not supported"); + + assert!( + error.to_string().contains("does not support"), + "unexpected error: {error}" + ); +} diff --git a/crates/workflow-github-actions/src/workflow/triggers.rs b/crates/workflow-github-actions/src/workflow/triggers.rs --- a/crates/workflow-github-actions/src/workflow/triggers.rs +++ b/crates/workflow-github-actions/src/workflow/triggers.rs @@ -1,119 +1,133 @@ -use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers}; - -use super::{Node, WorkflowModelError}; - -/// Read `on:` in the three shapes the dialect allows: a single event, a -/// sequence of events, or a mapping of events to their filters. -pub fn read(root: &Node) -> Result { - let Some(node) = root.get("on") else { - return Ok(Triggers::default()); - }; - let triggers = - match node { - Node::String(name) => kind(name)? - .map(|kind| Trigger::new(kind, Filter::default(), Filter::default())) - .into_iter() - .collect(), - Node::Sequence(names) => names - .iter() - .map(|node| { - let name = node.as_str().ok_or_else(|| unsupported("$.on"))?; - Ok(kind(name)? - .map(|kind| Trigger::new(kind, Filter::default(), Filter::default()))) - }) - .collect::, WorkflowModelError>>()? - .into_iter() - .flatten() - .collect(), - Node::Mapping(entries) => entries - .iter() - .map(|(name, node)| read_trigger(name, node)) - .collect::, WorkflowModelError>>()? - .into_iter() - .flatten() - .collect(), - _ => return Err(unsupported("$.on")), - }; - Ok(Triggers::new(triggers)) -} - -fn read_trigger(name: &str, node: &Node) -> Result, WorkflowModelError> { - let Some(kind) = kind(name)? else { - return Ok(None); - }; - let path = format!("$.on.{name}"); - - // A schedule is written as a sequence of cron entries, because it says when - // it fires rather than what it fires for. There is no branch or path to - // filter on, and reading it as a mapping like the others refuses a workflow - // that is written exactly as the dialect says to write one. - if kind == EventKind::Schedule { - node.as_sequence().ok_or_else(|| unsupported(&path))?; - return Ok(Some(Trigger::new( - kind, - Filter::default(), - Filter::default(), - ))); - } - - let (Node::Mapping(_) | Node::Null) = node else { - return Err(unsupported(&path)); - }; - Ok(Some(Trigger::new( - kind, - filter(node, "branches", "branches-ignore", &path)?, - filter(node, "paths", "paths-ignore", &path)?, - ))) -} - -fn filter( - node: &Node, - include: &str, - exclude: &str, - path: &str, -) -> Result { - Ok(Filter::new( - patterns(node.get(include), &format!("{path}.{include}"))?, - patterns(node.get(exclude), &format!("{path}.{exclude}"))?, - )) -} - -fn patterns(node: Option<&Node>, path: &str) -> Result, WorkflowModelError> { - let Some(node) = node else { - return Ok(Vec::new()); - }; - node.as_sequence() - .ok_or_else(|| unsupported(path))? - .iter() - .map(|value| { - let value = value.as_str().ok_or_else(|| unsupported(path))?; - Pattern::parse(value).map_err(|error| WorkflowModelError::UnsupportedTrigger { - path: path.to_owned(), - reason: error.to_string(), - }) - }) - .collect() -} - -/// `workflow_call` says the workflow can be called, not that something happened, -/// so it reads as no trigger at all rather than as an unknown event. -fn kind(name: &str) -> Result, WorkflowModelError> { - match name { - "push" => Ok(Some(EventKind::Push)), - "pull_request" => Ok(Some(EventKind::PullRequest)), - "workflow_dispatch" => Ok(Some(EventKind::Manual)), - "schedule" => Ok(Some(EventKind::Schedule)), - "workflow_call" => Ok(None), - other => Err(WorkflowModelError::UnsupportedTrigger { - path: "$.on".to_owned(), - reason: format!("{other:?} is not an event this control plane acts on"), - }), - } -} - -fn unsupported(path: &str) -> WorkflowModelError { - WorkflowModelError::UnsupportedTrigger { - path: path.to_owned(), - reason: "the declaration has a shape this compiler does not read".to_owned(), - } -} +use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers}; + +use super::{Node, WorkflowModelError}; + +/// Read `on:` in the three shapes the dialect allows: a single event, a +/// sequence of events, or a mapping of events to their filters. +pub fn read(root: &Node) -> Result { + let Some(node) = root.get("on") else { + return Ok(Triggers::default()); + }; + let triggers = match node { + Node::String(name) => kind(name)? + .map(|kind| { + Trigger::new( + kind, + Filter::default(), + Filter::default(), + Filter::default(), + ) + }) + .into_iter() + .collect(), + Node::Sequence(names) => names + .iter() + .map(|node| { + let name = node.as_str().ok_or_else(|| unsupported("$.on"))?; + Ok(kind(name)?.map(|kind| { + Trigger::new( + kind, + Filter::default(), + Filter::default(), + Filter::default(), + ) + })) + }) + .collect::, WorkflowModelError>>()? + .into_iter() + .flatten() + .collect(), + Node::Mapping(entries) => entries + .iter() + .map(|(name, node)| read_trigger(name, node)) + .collect::, WorkflowModelError>>()? + .into_iter() + .flatten() + .collect(), + _ => return Err(unsupported("$.on")), + }; + Ok(Triggers::new(triggers)) +} + +fn read_trigger(name: &str, node: &Node) -> Result, WorkflowModelError> { + let Some(kind) = kind(name)? else { + return Ok(None); + }; + let path = format!("$.on.{name}"); + + // A schedule is written as a sequence of cron entries, because it says when + // it fires rather than what it fires for. There is no branch or path to + // filter on, and reading it as a mapping like the others refuses a workflow + // that is written exactly as the dialect says to write one. + if kind == EventKind::Schedule { + node.as_sequence().ok_or_else(|| unsupported(&path))?; + return Ok(Some(Trigger::new( + kind, + Filter::default(), + Filter::default(), + Filter::default(), + ))); + } + + let (Node::Mapping(_) | Node::Null) = node else { + return Err(unsupported(&path)); + }; + Ok(Some(Trigger::new( + kind, + filter(node, "branches", "branches-ignore", &path)?, + filter(node, "tags", "tags-ignore", &path)?, + filter(node, "paths", "paths-ignore", &path)?, + ))) +} + +fn filter( + node: &Node, + include: &str, + exclude: &str, + path: &str, +) -> Result { + Ok(Filter::new( + patterns(node.get(include), &format!("{path}.{include}"))?, + patterns(node.get(exclude), &format!("{path}.{exclude}"))?, + )) +} + +fn patterns(node: Option<&Node>, path: &str) -> Result, WorkflowModelError> { + let Some(node) = node else { + return Ok(Vec::new()); + }; + node.as_sequence() + .ok_or_else(|| unsupported(path))? + .iter() + .map(|value| { + let value = value.as_str().ok_or_else(|| unsupported(path))?; + Pattern::parse(value).map_err(|error| WorkflowModelError::UnsupportedTrigger { + path: path.to_owned(), + reason: error.to_string(), + }) + }) + .collect() +} + +/// `workflow_call` says the workflow can be called, not that something happened, +/// so it reads as no trigger at all rather than as an unknown event. +fn kind(name: &str) -> Result, WorkflowModelError> { + match name { + "push" => Ok(Some(EventKind::Push)), + "pull_request" => Ok(Some(EventKind::PullRequest)), + "workflow_dispatch" => Ok(Some(EventKind::Manual)), + "schedule" => Ok(Some(EventKind::Schedule)), + "workflow_call" => Ok(None), + other => Err(WorkflowModelError::UnsupportedTrigger { + path: "$.on".to_owned(), + reason: format!("{other:?} is not an event this control plane acts on"), + }), + } +} + +fn unsupported(path: &str) -> WorkflowModelError { + WorkflowModelError::UnsupportedTrigger { + path: path.to_owned(), + reason: "the declaration has a shape this compiler does not read".to_owned(), + } +} -- SynCode