Preflight workflow event declarations #19

Manually merged
day01 merged 1 commits from feat/0.6-event-declaration-preflight into develop 2026-08-31 08:40:47 +00:00
3 changed files with 42 additions and 1 deletions
Showing only changes of commit 7d72ace2ff - Show all commits
@@ -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}"
);
}
@@ -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;
@@ -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<Triggers, WorkflowModelError> {
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::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
Node::Mapping(entries) => entries
.iter()
.map(|(name, node)| read_trigger(name, node))
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
_ => return Err(unsupported("$.on")),
};
Ok(Triggers::new(triggers))
}

fn read_trigger(name: &str, node: &Node) -> Result<Option<Trigger>, 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<Filter, WorkflowModelError> {
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<Vec<Pattern>, 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<Option<EventKind>, 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<bool, WorkflowModelError> {
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<Triggers, WorkflowModelError> {
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::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
Node::Mapping(entries) => entries
.iter()
.map(|(name, node)| read_trigger(name, node))
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
_ => return Err(unsupported("$.on")),
};
Ok(Triggers::new(triggers))
}

fn read_trigger(name: &str, node: &Node) -> Result<Option<Trigger>, 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<Filter, WorkflowModelError> {
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<Vec<Pattern>, 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<Option<EventKind>, 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(),
}
}