use crate::config::Level;
use crate::domain::file::parsed::{ParsedWorkflow, Trigger};
use crate::lint::{Context, Diagnostic, Rule, RuleName};
pub struct MissingConcurrencyRule;
impl MissingConcurrencyRule {
pub fn check_workflow(workflow: &ParsedWorkflow) -> Option<Diagnostic> {
let race_trigger = workflow.on.iter().find_map(|t| match t {
Trigger::Push => Some("push"),
Trigger::Schedule => Some("schedule"),
Trigger::PullRequest
| Trigger::PullRequestTarget
| Trigger::WorkflowDispatch
| Trigger::WorkflowCall
| Trigger::WorkflowRun
| Trigger::Release
| Trigger::Tags
| Trigger::Other(_) => None,
})?;
if workflow.concurrency.is_some() {
return None;
}
let msg = format!(
"workflow triggered by `{race_trigger}` has no `concurrency:` block — add one (e.g. `concurrency: {{ group: ${{{{ github.workflow }}}}-${{{{ github.ref }}}}, cancel-in-progress: true }}`) to serialize racing runs"
);
Some(
Diagnostic::new(RuleName::MissingConcurrency, Level::Warn, msg)
.with_workflow(workflow.path.clone()),
)
}
}
impl Rule for MissingConcurrencyRule {
fn name(&self) -> RuleName {
RuleName::MissingConcurrency
}
fn default_level(&self) -> Level {
Level::Warn
}
fn check(&self, ctx: &Context) -> Vec<Diagnostic> {
ctx.workflows_full
.iter()
.filter_map(Self::check_workflow)
.collect()
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests use unwrap freely")]
mod tests {
use super::*;
use crate::domain::file::parsed::Parsed;
use crate::domain::file::site::WorkflowPath;
fn parse(content: &str) -> ParsedWorkflow {
Parsed::from_yaml(WorkflowPath::new(".github/workflows/x.yml"), content).unwrap()
}
#[test]
fn rule_metadata() {
let r = MissingConcurrencyRule;
assert_eq!(r.name(), RuleName::MissingConcurrency);
assert_eq!(r.default_level(), Level::Warn);
}
#[test]
fn push_without_concurrency_warns() {
let p = parse("on: push\njobs: {}\n");
let d = MissingConcurrencyRule::check_workflow(&p).unwrap();
assert_eq!(d.level, Level::Warn);
assert!(d.message.contains("push"));
}
#[test]
fn push_with_concurrency_is_clean() {
let p = parse(
"on: push
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: {}
",
);
assert!(MissingConcurrencyRule::check_workflow(&p).is_none());
}
#[test]
fn pull_request_without_concurrency_is_clean() {
let p = parse("on: pull_request\njobs: {}\n");
assert!(MissingConcurrencyRule::check_workflow(&p).is_none());
}
#[test]
fn schedule_without_concurrency_warns() {
let p = parse(
"on:
schedule:
- cron: '0 0 * * *'
jobs: {}
",
);
let d = MissingConcurrencyRule::check_workflow(&p).unwrap();
assert!(d.message.contains("schedule"));
}
}