use github_actions_expressions::context::{self};
use github_actions_models::{
common::{self, expr::LoE},
workflow::{
self, Trigger,
event::{BareEvent, OptionalBody},
job::{self, RunsOn, StepBody},
},
};
use terminal_link::Link;
pub(crate) mod matrix;
use crate::{
InputKey,
finding::location::{Locatable, SymbolicFeature, SymbolicLocation},
models::{
AsDocument, StepBodyCommon, StepCommon,
inputs::{Capability, HasInputs},
workflow::matrix::Matrix,
},
registry::input::CollectionError,
utils::{self, WORKFLOW_VALIDATOR, from_str_with_validation},
};
pub(crate) struct Workflow {
pub(crate) key: InputKey,
pub(crate) link: Option<String>,
document: yamlpath::Document,
inner: workflow::Workflow,
}
impl<'a> AsDocument<'a, 'a> for Workflow {
fn as_document(&'a self) -> &'a yamlpath::Document {
&self.document
}
}
impl std::fmt::Debug for Workflow {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{key}", key = self.key)
}
}
impl std::ops::Deref for Workflow {
type Target = workflow::Workflow;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl HasInputs for workflow::event::WorkflowCall {
fn get_input(&self, name: &str) -> Option<Capability> {
let input = self.inputs.get(name)?;
Some(match input.r#type {
workflow::event::WorkflowCallInputType::Boolean => Capability::Fixed,
workflow::event::WorkflowCallInputType::Number => Capability::Fixed,
workflow::event::WorkflowCallInputType::String => Capability::Arbitrary,
})
}
}
impl HasInputs for workflow::event::WorkflowDispatch {
fn get_input(&self, name: &str) -> Option<Capability> {
let input = self.inputs.get(name)?;
Some(match input.r#type {
workflow::event::WorkflowDispatchInputType::Boolean => Capability::Fixed,
workflow::event::WorkflowDispatchInputType::Choice => Capability::Fixed,
workflow::event::WorkflowDispatchInputType::Environment => Capability::Fixed,
workflow::event::WorkflowDispatchInputType::Number => Capability::Fixed,
workflow::event::WorkflowDispatchInputType::String => Capability::Arbitrary,
})
}
}
impl HasInputs for Workflow {
fn get_input(&self, name: &str) -> Option<Capability> {
let workflow::Trigger::Events(events) = &self.on else {
return None;
};
let wc_cap = {
if let workflow::event::OptionalBody::Body(wc) = &events.workflow_call {
wc.get_input(name)
} else {
None
}
};
let wd_cap = {
if let workflow::event::OptionalBody::Body(wd) = &events.workflow_dispatch {
wd.get_input(name)
} else {
None
}
};
match (wc_cap, wd_cap) {
(Some(cap1), Some(cap2)) => Some(cap1.unify(cap2)),
(Some(single), None) | (None, Some(single)) => Some(single),
(None, None) => None,
}
}
}
impl Workflow {
pub(crate) fn from_string(contents: String, key: InputKey) -> Result<Self, CollectionError> {
let inner = from_str_with_validation(&contents, &WORKFLOW_VALIDATOR)?;
let document = yamlpath::Document::new(&contents)?;
let link = match key {
InputKey::Local(_) | InputKey::Stdin(_) => None,
InputKey::Remote(_) => {
Some(Link::new(key.presentation_path(), &key.to_string()).to_string())
}
};
Ok(Self {
link,
key,
document,
inner,
})
}
pub(crate) fn jobs(&self) -> Jobs<'_> {
Jobs::new(self)
}
pub(crate) fn has_pull_request_target(&self) -> bool {
match &self.on {
Trigger::BareEvent(event) => *event == BareEvent::PullRequestTarget,
Trigger::BareEvents(events) => events.contains(&BareEvent::PullRequestTarget),
Trigger::Events(events) => !matches!(events.pull_request_target, OptionalBody::Missing),
}
}
pub(crate) fn has_workflow_run(&self) -> bool {
match &self.on {
Trigger::BareEvent(event) => *event == BareEvent::WorkflowRun,
Trigger::BareEvents(events) => events.contains(&BareEvent::WorkflowRun),
Trigger::Events(events) => !matches!(events.workflow_run, OptionalBody::Missing),
}
}
pub(crate) fn has_workflow_call(&self) -> bool {
match &self.on {
Trigger::BareEvent(event) => *event == BareEvent::WorkflowCall,
Trigger::BareEvents(events) => events.contains(&BareEvent::WorkflowCall),
Trigger::Events(events) => !matches!(events.workflow_call, OptionalBody::Missing),
}
}
pub(crate) fn has_single_trigger(&self) -> bool {
match &self.on {
Trigger::BareEvent(_) => true,
Trigger::BareEvents(events) => events.len() == 1,
Trigger::Events(events) => events.count() == 1,
}
}
pub(crate) fn is_reusable_only(&self) -> bool {
self.has_workflow_call() && self.has_single_trigger()
}
pub fn location(&self) -> SymbolicLocation<'_> {
SymbolicLocation {
key: &self.key,
annotation: "this workflow".into(),
link: None,
route: Default::default(),
feature_kind: SymbolicFeature::Normal,
kind: Default::default(),
}
}
}
#[derive(Clone)]
pub(crate) struct NormalJob<'doc> {
id: &'doc str,
inner: &'doc job::NormalJob,
parent: &'doc Workflow,
}
impl<'doc> NormalJob<'doc> {
pub(crate) fn new(id: &'doc str, inner: &'doc job::NormalJob, parent: &'doc Workflow) -> Self {
Self { id, inner, parent }
}
pub(crate) fn matrix(&self) -> Option<Matrix<'doc>> {
Matrix::new(self)
}
pub(crate) fn steps(&self) -> Steps<'doc> {
Steps::new(self)
}
pub(crate) fn has_id_token(&self) -> bool {
let effective_permissions = match self.permissions {
common::Permissions::Base(common::BasePermission::Default) => &self.parent.permissions,
_ => &self.permissions,
};
match effective_permissions {
common::Permissions::Base(common::BasePermission::WriteAll) => true,
common::Permissions::Explicit(explicit) => explicit
.get("id-token")
.is_some_and(|perm| matches!(perm, common::Permission::Write)),
_ => false,
}
}
pub(crate) fn runner_default_shell(&self) -> Option<&'static str> {
match &self.runs_on {
LoE::Expr(_) => None,
LoE::Literal(RunsOn::Group { group: _, labels })
| LoE::Literal(RunsOn::Target(labels)) => {
for label in labels {
match label.as_str() {
"linux" | "macOS" => return Some("bash"),
"windows" => return Some("pwsh"),
l if l.contains("ubuntu-") || l.contains("macos") => return Some("bash"),
l if l.contains("windows-") => return Some("pwsh"),
_ => continue,
}
}
None
}
}
}
pub(crate) fn conditions(
&self,
) -> impl Iterator<Item = (&'doc common::If, SymbolicLocation<'doc>)> {
self.r#if.iter().map(|cond| (cond, self.location())).chain(
self.steps()
.filter_map(|step| step.r#if.as_ref().map(|cond| (cond, step.location()))),
)
}
}
impl<'a, 'doc> AsDocument<'a, 'doc> for NormalJob<'doc> {
fn as_document(&'a self) -> &'doc yamlpath::Document {
self.parent.as_document()
}
}
impl<'doc> JobCommon<'doc> for NormalJob<'doc> {
fn id(&self) -> &'doc str {
self.id
}
fn name(&self) -> Option<&'doc str> {
self.inner.name.as_deref()
}
fn parent(&self) -> &'doc Workflow {
self.parent
}
}
impl<'doc> std::ops::Deref for NormalJob<'doc> {
type Target = &'doc job::NormalJob;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
#[derive(Clone)]
pub(crate) struct ReusableWorkflowCallJob<'doc> {
id: &'doc str,
inner: &'doc job::ReusableWorkflowCallJob,
parent: &'doc Workflow,
}
impl<'doc> ReusableWorkflowCallJob<'doc> {
pub(crate) fn new(
id: &'doc str,
inner: &'doc job::ReusableWorkflowCallJob,
parent: &'doc Workflow,
) -> Self {
Self { id, inner, parent }
}
}
impl<'a, 'doc> AsDocument<'a, 'doc> for ReusableWorkflowCallJob<'doc> {
fn as_document(&'a self) -> &'doc yamlpath::Document {
self.parent.as_document()
}
}
impl<'doc> JobCommon<'doc> for ReusableWorkflowCallJob<'doc> {
fn id(&self) -> &'doc str {
self.id
}
fn name(&self) -> Option<&'doc str> {
self.inner.name.as_deref()
}
fn parent(&self) -> &'doc Workflow {
self.parent
}
}
impl<'doc> std::ops::Deref for ReusableWorkflowCallJob<'doc> {
type Target = &'doc job::ReusableWorkflowCallJob;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
pub(crate) trait JobCommon<'doc>: Locatable<'doc> {
fn id(&self) -> &'doc str;
fn name(&self) -> Option<&'doc str>;
fn parent(&self) -> &'doc Workflow;
}
impl<'doc, T: JobCommon<'doc>> Locatable<'doc> for T {
fn location(&self) -> SymbolicLocation<'doc> {
self.parent()
.location()
.annotated("this job")
.with_keys(["jobs".into(), self.id().into()])
}
fn location_with_grip(&self) -> SymbolicLocation<'doc> {
if self.name().is_some() {
self.location().with_keys(["name".into()])
} else {
self.parent()
.location()
.annotated("this job")
.with_keys(["jobs".into(), self.id().into()])
.key_only()
}
}
}
#[derive(Clone)]
pub(crate) enum Job<'doc> {
NormalJob(NormalJob<'doc>),
ReusableWorkflowCallJob(ReusableWorkflowCallJob<'doc>),
}
impl<'doc> Job<'doc> {
fn new(id: &'doc str, inner: &'doc workflow::Job, parent: &'doc Workflow) -> Self {
match inner {
workflow::Job::NormalJob(normal) => Job::NormalJob(NormalJob::new(id, normal, parent)),
workflow::Job::ReusableWorkflowCallJob(reusable) => {
Job::ReusableWorkflowCallJob(ReusableWorkflowCallJob::new(id, reusable, parent))
}
}
}
}
pub(crate) struct Jobs<'doc> {
parent: &'doc Workflow,
inner: indexmap::map::Iter<'doc, String, workflow::Job>,
}
impl<'doc> Jobs<'doc> {
fn new(workflow: &'doc Workflow) -> Self {
Self {
parent: workflow,
inner: workflow.jobs.iter(),
}
}
}
impl<'doc> Iterator for Jobs<'doc> {
type Item = Job<'doc>;
fn next(&mut self) -> Option<Self::Item> {
let item = self.inner.next();
match item {
Some((id, job)) => Some(Job::new(id, job, self.parent)),
None => None,
}
}
}
#[derive(Clone)]
pub(crate) struct Step<'doc> {
pub(crate) index: usize,
inner: &'doc workflow::job::Step,
pub(crate) parent: NormalJob<'doc>,
}
impl<'doc> std::ops::Deref for Step<'doc> {
type Target = &'doc workflow::job::Step;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<'doc> Locatable<'doc> for Step<'doc> {
fn location(&self) -> SymbolicLocation<'doc> {
self.parent
.location()
.with_keys(["steps".into(), self.index.into()])
.annotated("this step")
}
fn location_with_grip(&self) -> SymbolicLocation<'doc> {
if self.inner.name.is_some() {
self.location().with_keys(["name".into()])
} else if self.inner.id.is_some() {
self.location().with_keys(["id".into()])
} else {
self.location()
}
}
}
impl HasInputs for Step<'_> {
fn get_input(&self, name: &str) -> Option<Capability> {
self.workflow().get_input(name)
}
}
impl<'doc> StepCommon<'doc> for Step<'doc> {
fn index(&self) -> usize {
self.index
}
fn env_is_static(&self, ctx: &context::Context) -> bool {
utils::env_is_static(ctx, &[&self.env, &self.job().env, &self.workflow().env])
}
fn uses(&self) -> Option<&'doc common::Uses> {
let StepBody::Uses { uses, .. } = &self.inner.body else {
return None;
};
Some(uses)
}
fn matrix(&self) -> Option<Matrix<'doc>> {
self.job().matrix()
}
fn body(&self) -> StepBodyCommon<'doc> {
match &self.body {
StepBody::Uses { uses, with } => StepBodyCommon::Uses { uses, with },
StepBody::Run {
run,
working_directory,
shell,
} => StepBodyCommon::Run {
run,
_working_directory: working_directory.as_deref(),
_shell: shell.as_ref(),
},
}
}
fn document(&self) -> &'doc yamlpath::Document {
self.workflow().as_document()
}
fn shell(&self) -> Option<(&str, SymbolicLocation<'doc>)> {
self.shell()
}
}
impl<'doc> Step<'doc> {
fn new(index: usize, inner: &'doc workflow::job::Step, parent: NormalJob<'doc>) -> Self {
Self {
index,
inner,
parent,
}
}
pub(crate) fn job(&self) -> &NormalJob<'doc> {
&self.parent
}
pub(crate) fn workflow(&self) -> &'doc Workflow {
self.parent.parent()
}
pub(crate) fn shell(&self) -> Option<(&str, SymbolicLocation<'doc>)> {
let StepBody::Run {
run: _,
working_directory: _,
shell,
} = &self.inner.body
else {
panic!("API misuse: can't call shell() on a uses: step")
};
let shell = match shell {
Some(LoE::Literal(shell)) => Some((
shell.as_str(),
self.location()
.with_keys(["shell".into()])
.annotated("shell defined here"),
)),
Some(LoE::Expr(_)) => return None,
None => match self
.job()
.defaults
.as_ref()
.and_then(|d| d.run.as_ref().and_then(|r| r.shell.as_ref()))
{
Some(LoE::Literal(shell)) => Some((
shell.as_str(),
self.job()
.location()
.with_keys(["defaults".into(), "run".into(), "shell".into()])
.annotated("job default shell defined here"),
)),
Some(LoE::Expr(_)) => return None,
None => match self
.workflow()
.defaults
.as_ref()
.and_then(|d| d.run.as_ref().and_then(|r| r.shell.as_ref()))
{
Some(LoE::Literal(shell)) => Some((
shell.as_str(),
self.workflow()
.location()
.with_keys(["defaults".into(), "run".into(), "shell".into()])
.annotated("workflow default shell defined here"),
)),
Some(LoE::Expr(_)) => return None,
None => None,
},
},
};
shell.or_else(|| {
self.parent.runner_default_shell().map(|shell| {
(
shell,
self.job()
.location()
.with_keys(["runs-on".into()])
.annotated("shell implied by runner"),
)
})
})
}
}
pub(crate) struct Steps<'doc> {
inner: std::iter::Enumerate<std::slice::Iter<'doc, github_actions_models::workflow::job::Step>>,
parent: NormalJob<'doc>,
}
impl<'doc> Steps<'doc> {
fn new(job: &NormalJob<'doc>) -> Self {
Self {
inner: job.steps.iter().enumerate(),
parent: job.clone(),
}
}
}
impl<'doc> Iterator for Steps<'doc> {
type Item = Step<'doc>;
fn next(&mut self) -> Option<Self::Item> {
let item = self.inner.next();
match item {
Some((idx, step)) => Some(Step::new(idx, step, self.parent.clone())),
None => None,
}
}
}
#[cfg(test)]
mod tests {
use crate::models::{
inputs::{Capability, HasInputs as _},
workflow::Workflow,
};
#[test]
fn test_workflow_has_inputs() -> anyhow::Result<()> {
let workflow = r#"
name: Test Workflow
on:
workflow_dispatch:
inputs:
foo:
type: string
required: true
bar:
type: boolean
required: false
workflow_call:
inputs:
foo:
type: number
required: true
bar:
type: boolean
required: false
jobs:
test_job:
runs-on: ubuntu-latest
steps:
- run: true
"#;
let workflow = Workflow::from_string(
workflow.into(),
crate::InputKey::local("fakegroup".into(), "dummy", None),
)?;
let foo_cap = workflow.get_input("foo").unwrap();
assert_eq!(foo_cap, Capability::Arbitrary);
let bar_cap = workflow.get_input("bar").unwrap();
assert_eq!(bar_cap, Capability::Fixed);
Ok(())
}
}