From 7d72ace2ffff04704f4180205ce5fa9eb5037aaa Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Mon, 31 Aug 2026 10:40:26 +0200 Subject: [PATCH] feat: Preflight workflow event declarations --- 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,217 +1,236 @@ -//! 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}" - ); -} +//! 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::EventKind; +use syncode_workflow_github_actions::workflow::{Workflow, declares_event, 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 an_unsupported_event_does_not_declare_an_unrelated_supported_event() { + let node = parse( + r#" +on: [pull_request_target] +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo one +"#, + ) + .unwrap_or_else(|error| panic!("parse: {error}")); + + assert!(!declares_event(&node, EventKind::Push).expect("event declaration")); + assert!(Workflow::from_node(&node).is_err()); +} + +#[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-github-actions/src/workflow/mod.rs b/crates/workflow-github-actions/src/workflow/mod.rs --- a/crates/workflow-github-actions/src/workflow/mod.rs +++ b/crates/workflow-github-actions/src/workflow/mod.rs @@ -1,14 +1,15 @@ -mod error; -mod model; -mod model_error; -mod node; -mod parse; -mod read; -mod triggers; - -pub use error::WorkflowParseError; -pub use model::{Job, Step, StepKind, Workflow}; -pub use model_error::WorkflowModelError; -pub use node::Node; -pub use parse::parse; -pub use read::read_step; +mod error; +mod model; +mod model_error; +mod node; +mod parse; +mod read; +mod triggers; + +pub use error::WorkflowParseError; +pub use model::{Job, Step, StepKind, Workflow}; +pub use model_error::WorkflowModelError; +pub use node::Node; +pub use parse::parse; +pub use read::read_step; +pub use triggers::declares_event; 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,133 +1,154 @@ -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(), - } -} +use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers}; + +use super::{Node, WorkflowModelError}; + +pub fn declares_event(root: &Node, event: EventKind) -> Result { + let Some(node) = root.get("on") else { + return Ok(false); + }; + let expected = match event { + EventKind::Push => "push", + EventKind::PullRequest => "pull_request", + EventKind::Manual => "workflow_dispatch", + EventKind::Schedule => "schedule", + }; + match node { + Node::String(name) => Ok(name == expected), + Node::Sequence(names) => names.iter().try_fold(false, |declared, node| { + let name = node.as_str().ok_or_else(|| unsupported("$.on"))?; + Ok(declared || name == expected) + }), + Node::Mapping(entries) => Ok(entries.iter().any(|(name, _)| name == expected)), + _ => Err(unsupported("$.on")), + } +} + +/// 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