use std::path::Path;
use fairlead_core::config::{CiStage, Config};
const LIVE: [&str; 6] = [
"--prod",
"helm upgrade",
"argocd app sync",
"fly deploy",
"firebase deploy",
"kubectl apply",
];
const LIVE_ENVIRONMENTS: [&str; 3] = ["production", "prod", "live"];
fn environment_name(line: &str, after_bare: bool) -> Option<&str> {
let value = match line.strip_prefix("environment:") {
Some(v) => v,
None if after_bare => line.strip_prefix("name:")?,
None => return None,
};
let value = value.trim().trim_matches(|c| c == '\'' || c == '"');
(!value.is_empty()).then_some(value)
}
pub struct Workflow {
pub name: String,
pub text: String,
}
pub fn workflows(root: &Path) -> Vec<Workflow> {
let dir = root.join(".github/workflows");
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out: Vec<Workflow> = entries
.filter_map(Result::ok)
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.ends_with(".yml") || name.ends_with(".yaml")
})
.filter_map(|e| {
let text = std::fs::read_to_string(e.path()).ok()?;
Some(Workflow {
name: format!(".github/workflows/{}", e.file_name().to_string_lossy()),
text,
})
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn live_evidence(workflows: &[Workflow]) -> Vec<String> {
let mut out = Vec::new();
for w in workflows {
let mut after_bare = false;
for (n, line) in w.text.lines().enumerate() {
let line = line.trim().to_ascii_lowercase();
if line.is_empty() || line.starts_with('#') {
continue;
}
let env = environment_name(&line, after_bare);
after_bare = line == "environment:";
let live_env = env.is_some_and(|e| LIVE_ENVIRONMENTS.contains(&e));
if live_env || LIVE.iter().any(|m| line.contains(m)) {
out.push(format!("{}:{}", w.name, n + 1));
break;
}
}
}
out
}
pub fn scheduled_plan(workflows: &[Workflow]) -> bool {
scheduled_workflow(workflows).is_some()
}
pub fn scheduled_workflow(workflows: &[Workflow]) -> Option<&str> {
workflows
.iter()
.find(|w| {
let lines: Vec<&str> = w.text.lines().map(str::trim).collect();
lines.iter().any(|l| l.starts_with("schedule:"))
&& lines.iter().any(|l| l.contains("fairlead ci plan"))
})
.map(|w| w.name.rsplit('/').next().unwrap_or(&w.name))
}
fn at_full(config: &Config) -> Vec<String> {
let runners = config
.tests
.runners
.items()
.iter()
.map(|r| (&r.id, r.all_from));
let checks = config.checks.items().iter().map(|c| (&c.id, c.all_from));
runners
.chain(checks)
.filter(|(_, set)| config.all_from_of(*set) == Some(CiStage::Full))
.map(|(id, _)| id.clone())
.collect()
}
pub fn doctor_lines(root: &Path, config: &Config) -> String {
let found = workflows(root);
let live = live_evidence(&found);
let mut out = match (config.phase, live.first()) {
(Some(p), Some(at)) if !p.is_live() => format!(
"phase: {} (set), but {at} looks like a deploy to people; once one is live, set `phase = \"live\"`\n",
p.name()
),
(Some(p), _) => format!("phase: {} (set)\n", p.name()),
(None, Some(at)) => format!(
"phase: not set; {at} looks like a deploy to people, so whole suites run as each step says (`phase = \"live\"` says so)\n"
),
(None, None) => "phase: not set, and no workflow deploys to people; if nothing is live, `phase = \"early\"` leaves whole suites to the end of the day\n".to_string(),
};
let waiting = at_full(config);
if !waiting.is_empty() && !scheduled_plan(&found) {
out.push_str(&format!(
" whole suites of {} run only at the full stage, and no scheduled workflow runs `fairlead ci plan`: `fairlead ci workflow --write` adds the schedule\n",
waiting.join(", ")
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use fairlead_core::config::Phase;
fn wf(name: &str, text: &str) -> Workflow {
Workflow {
name: name.into(),
text: text.into(),
}
}
#[test]
fn a_production_environment_is_live_evidence_and_a_comment_isnt() {
let found = [
wf("a.yml", "jobs:\n ship:\n environment: production\n"),
wf("b.yml", "# environment: production\nrun: echo\n"),
];
assert_eq!(live_evidence(&found), vec!["a.yml:3".to_string()]);
}
#[test]
fn an_environment_named_on_its_own_line_is_live_evidence_and_staging_isnt() {
let found = [
wf(
"prod.yml",
"jobs:\n ship:\n environment:\n name: production\n url: https://x\n",
),
wf("quoted.yml", "jobs:\n ship:\n environment: 'live'\n"),
wf(
"stage.yml",
"jobs:\n ship:\n environment:\n name: staging\n steps:\n - name: production smoke\n",
),
];
assert_eq!(
live_evidence(&found),
vec!["prod.yml:4".to_string(), "quoted.yml:3".to_string()]
);
}
#[test]
fn a_schedule_counts_only_with_a_ci_plan() {
let nightly = wf("n.yml", "on:\n schedule:\n - cron: \"1 2 * * *\"\nsteps:\n - run: fairlead ci plan --out p.json\n");
let insights = wf(
"i.yml",
"on:\n schedule:\n - cron: \"1 2 * * 1\"\nsteps:\n - run: fairlead insights\n",
);
let named = [
wf(".github/workflows/i.yml", &insights.text),
wf(".github/workflows/n.yml", &nightly.text),
];
assert_eq!(scheduled_workflow(&named), Some("n.yml"));
assert!(scheduled_plan(&[nightly]));
assert!(!scheduled_plan(&[insights]));
}
#[test]
fn early_with_a_deploy_says_to_set_live() {
let dir = std::env::temp_dir().join(format!("fl-phase-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".github/workflows")).unwrap();
std::fs::write(
dir.join(".github/workflows/ship.yml"),
"jobs:\n ship:\n steps:\n - run: helm upgrade api ./chart\n",
)
.unwrap();
let config = Config {
phase: Some(Phase::Early),
..Config::default()
};
let text = doctor_lines(&dir, &config);
assert!(
text.contains("phase: early (set), but .github/workflows/ship.yml:4"),
"{text}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
}