Preflight workflow event declarations #19
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user