//! `fairlead ci workflow`: a staged GitHub Actions workflow made from the
//! config. A plan job; a `checks` job for every step that runs on each
//! change, cancelled by a newer push; and one job per runner or check that
//! waits for the merge stage, queued per branch so it runs once per batch.
use std::fmt::Write as _;
use std::path::Path;
use std::process::ExitCode;
use fairlead_core::config::{self, CiStage, Config, LoadOptions};
use fairlead_core::plan::InvocationKind;
use crate::stage::{from_of, output_name};
pub const PATH: &str = ".github/workflows/fairlead.yml";
pub const MARKER: &str = "# Generated by `fairlead ci workflow`";
// Pinned as this repository's own workflows pin them, which a test holds.
pub const CHECKOUT: &str = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1";
pub const UPLOAD: &str = "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7";
pub const DOWNLOAD: &str =
"actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8";
// The action installs the release `version` names, so its own commit can lag.
pub const ACTION: &str = "mahkassem/fairlead@3a251540c5c8bfec080a5bdd953d4030ba5f3c26 # v0.7.1";
const PLAN_FILE: &str = "fairlead-plan.json";
const RESERVED: [&str; 2] = ["plan", "checks"];
/// A runner or check that gets a job of its own.
struct Gated {
id: String,
job: String,
output: String,
}
pub fn run(cwd: &Path, write: bool, insights: bool) -> Result<ExitCode, String> {
let loaded =
config::load(cwd, &LoadOptions::from_process(Vec::new())).map_err(|e| e.to_string())?;
if let Some(p) = loaded.problems.first() {
return Err(format!(
"the config has problems (see `fairlead config check`): {}: {}",
p.key, p.message
));
}
if insights {
let text = render_insights(&insights_config(&crate::graph_cmd::repo_root(cwd)));
if !write {
print!("{text}");
return Ok(ExitCode::SUCCESS);
}
let verb = write_generated(&crate::graph_cmd::repo_root(cwd).join(INSIGHTS_PATH), &text)?;
println!("fairlead: {verb} {INSIGHTS_PATH}");
return Ok(ExitCode::SUCCESS);
}
let text = render(&loaded.config)?;
let gated = gated(&loaded.config)?;
let hints = hints(&gated);
if !write {
print!("{text}");
eprint!("{hints}");
return Ok(ExitCode::SUCCESS);
}
let verb = write_generated(&crate::graph_cmd::repo_root(cwd).join(PATH), &text)?;
println!("fairlead: {verb} {PATH}");
print!("{hints}");
Ok(ExitCode::SUCCESS)
}
/// Writes a generated workflow, replacing only a file this command wrote, and
/// says whether it wrote, rewrote or left it unchanged.
fn write_generated(path: &Path, text: &str) -> Result<&'static str, String> {
let verb = match std::fs::read_to_string(path) {
Ok(old) if !old.starts_with(MARKER) => {
return Err(format!(
"{} wasn't written by `fairlead ci workflow`, so it's left alone; move it aside or name its jobs' `if:` lines yourself (`fairlead ci workflow` prints them)",
path.display()
))
}
Ok(old) if old == text => "unchanged",
Ok(_) => "rewrote",
Err(_) => "wrote",
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
}
std::fs::write(path, text).map_err(|e| format!("could not write {}: {e}", path.display()))?;
Ok(verb)
}
/// A job id GitHub accepts: letters, digits, `-` and `_`, not starting with a digit or `-`.
fn job_id(id: &str) -> String {
let safe: String = id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
if safe.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
safe
} else {
format!("_{safe}")
}
}
/// The ids, runners first, with their kind.
fn ids(config: &Config) -> Vec<(&str, InvocationKind)> {
let runners = config.tests.runners.items().iter();
let checks = config.checks.items().iter();
runners
.map(|r| (r.id.as_str(), InvocationKind::Runner))
.chain(checks.map(|c| (c.id.as_str(), InvocationKind::Check)))
.collect()
}
/// Runners and checks from the merge stage on. Two that would share a job
/// or an output are an error, not a guess.
fn gated(config: &Config) -> Result<Vec<Gated>, String> {
let mut out: Vec<Gated> = Vec::new();
for (id, kind) in ids(config) {
if from_of(config, id, &kind) < CiStage::Merge || out.iter().any(|g| g.id == id) {
continue;
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(format!(
"`{id}` goes into a run line as written, so it may hold only letters, digits, `-`, `_` and `.`; rename it"
));
}
let job = job_id(id);
if RESERVED.contains(&job.as_str()) {
return Err(format!(
"`{id}` would be the job `{job}`, which the workflow already has; rename it"
));
}
if let Some(other) = out.iter().find(|g| g.job == job) {
return Err(format!(
"`{}` and `{id}` both become the job `{job}`; rename one",
other.id
));
}
out.push(Gated {
id: id.to_string(),
output: output_name(id),
job,
});
}
Ok(out)
}
/// A YAML scalar: plain when that can't be misread, else single-quoted.
fn scalar(text: &str) -> String {
let keyword = ["true", "false", "yes", "no", "on", "off", "null", "y", "n"]
.contains(&text.to_ascii_lowercase().as_str());
let plain = !keyword
&& text.starts_with(|c: char| c.is_ascii_alphabetic())
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'));
if plain {
text.to_string()
} else {
format!("'{}'", text.replace('\'', "''"))
}
}
fn install(out: &mut String) {
let version = env!("CARGO_PKG_VERSION");
let _ = write!(
out,
" - name: Install fairlead\n uses: {ACTION}\n with:\n version: v{version}\n command: --version\n"
);
}
fn header(out: &mut String, config: &Config) {
let _ = writeln!(
out,
"{MARKER} from the Fairlead config.\n# Change the config and run `fairlead ci workflow --write` again rather\n# than editing this file, which that command replaces.\nname: fairlead\n"
);
out.push_str("on:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled, unlabeled]\n push:\n branches:\n");
for branch in config.stages_or_default().environments.items() {
let _ = writeln!(out, " - {}", scalar(branch));
}
if config.has_all_from() {
let _ = writeln!(
out,
" schedule:\n - cron: {} # the full stage: every whole suite, in UTC",
scalar(&config.stages_or_default().nightly)
);
}
out.push_str(" workflow_dispatch:\n\npermissions:\n contents: read # each job widens only what it needs\n\njobs:\n");
}
fn plan_job(out: &mut String, config: &Config, gated: &[Gated]) -> Result<(), String> {
out.push_str(" plan:\n name: plan\n runs-on: ubuntu-latest\n permissions:\n contents: read # the history the plan diffs\n");
if !gated.is_empty() {
out.push_str(" actions: read # past runs, for --since-green\n");
}
if reuses(config) {
out.push_str(" pull-requests: read # the pull request a merge came from\n statuses: read # the tree it passed, for reuse at merge\n");
}
out.push_str(" outputs:\n stage: ${{ steps.plan.outputs.stage }}\n");
let mut seen: Vec<(String, &str)> = Vec::new();
for (id, _) in ids(config) {
let name = output_name(id);
match seen.iter().find(|(n, _)| *n == name) {
Some((_, other)) if *other != id => {
return Err(format!(
"`{other}` and `{id}` both become the output {name}; rename one"
))
}
Some(_) => continue,
None => seen.push((name.clone(), id)),
}
let _ = writeln!(out, " {name}: ${{{{ steps.plan.outputs.{name} }}}}");
}
let _ = write!(
out,
" steps:\n - uses: {CHECKOUT}\n with:\n fetch-depth: 0\n persist-credentials: false\n"
);
install(out);
out.push_str(" - name: Plan\n id: plan\n env:\n BASE: ${{ github.event.pull_request.base.sha }}\n");
if !gated.is_empty() || reuses(config) {
out.push_str(" GITHUB_TOKEN: ${{ github.token }}\n");
}
let _ = writeln!(
out,
" run: |\n args=(ci plan --format github --out \"$RUNNER_TEMP/{PLAN_FILE}\")"
);
if !gated.is_empty() {
let green: Vec<String> = gated
.iter()
.map(|g| format!("--since-green {}", g.id))
.collect();
let _ = writeln!(out, " args+=({})", green.join(" "));
}
let _ = write!(
out,
" if [ -n \"$BASE\" ]; then args+=(--base \"$BASE\"); fi\n fairlead \"${{args[@]}}\"\n - uses: {UPLOAD}\n with:\n name: fairlead-plan\n path: ${{{{ runner.temp }}}}/{PLAN_FILE}\n if-no-files-found: error\n"
);
Ok(())
}
/// A job that downloads the plan and runs part of it.
struct RunJob<'a> {
job: &'a str,
/// What the Actions API calls it, which `--since-green` matches.
name: &'a str,
gate: Option<&'a str>,
group: String,
cancel: bool,
args: String,
/// Records the passed tree as a commit status, for reuse at merge.
records: bool,
}
/// Whether the ready stage records its tree and the merge stage reads it back.
fn reuses(config: &Config) -> bool {
config.stages.as_ref().is_some_and(|s| s.reuse)
}
fn run_job(out: &mut String, j: RunJob) {
let RunJob {
job,
name,
gate,
group,
cancel,
args,
records,
} = j;
let _ = writeln!(
out,
"\n {job}:\n name: {}\n needs: plan",
scalar(name)
);
if let Some(output) = gate {
let _ = writeln!(out, " if: needs.plan.outputs.{output} == 'true'");
}
out.push_str(" runs-on: ubuntu-latest\n permissions:\n contents: read # check out the code the plan names\n");
if records {
out.push_str(
" statuses: write # fairlead/tree, so a merge of this tree reuses the run\n",
);
}
let _ = write!(
out,
" concurrency:\n group: {group}\n cancel-in-progress: {cancel}\n steps:\n - uses: {CHECKOUT}\n with:\n persist-credentials: false\n"
);
install(out);
let _ = write!(
out,
" - uses: {DOWNLOAD}\n with:\n name: fairlead-plan\n path: ${{{{ runner.temp }}}}\n - name: Run\n"
);
if records {
out.push_str(" env:\n GITHUB_TOKEN: ${{ github.token }}\n");
}
let _ = writeln!(
out,
" run: |\n fairlead ci run --plan \"$RUNNER_TEMP/{PLAN_FILE}\"{args}"
);
}
/// The workflow for `config`.
pub fn render(config: &Config) -> Result<String, String> {
let gated = gated(config)?;
let mut out = String::new();
header(&mut out, config);
plan_job(&mut out, config, &gated)?;
let except: String = gated
.iter()
.map(|g| format!(" --except {}", g.id))
.collect();
let group =
"${{ github.workflow }}-checks-${{ github.event.pull_request.number || github.ref }}";
run_job(
&mut out,
RunJob {
job: "checks",
name: "checks",
gate: None,
group: group.into(),
cancel: true,
args: except,
records: reuses(config),
},
);
for g in &gated {
run_job(
&mut out,
RunJob {
job: &g.job,
name: &g.id,
gate: Some(&g.output),
group: format!("${{{{ github.workflow }}}}-{}-${{{{ github.ref }}}}", g.job),
cancel: false,
args: format!(" --only {}", g.id),
records: false,
},
);
}
Ok(out)
}
/// Where `--insights` writes the weekly job, apart from the test workflow so
/// a schedule never starts a full test run.
pub const INSIGHTS_PATH: &str = ".github/workflows/fairlead-insights.yml";
/// The config the weekly job replays with, from the repository root: the
/// file the planner finds there, or `fairlead.toml`.
pub fn insights_config(root: &Path) -> String {
config::find_config(root)
.ok()
.flatten()
.and_then(|p| {
p.strip_prefix(root)
.ok()
.map(|r| r.to_string_lossy().replace('\\', "/"))
})
.unwrap_or_else(|| "fairlead.toml".into())
}
/// A weekly job that replays the test workflow's history and puts
/// `fairlead insights` in the run's summary. It runs no tests.
pub fn render_insights(config: &str) -> String {
let mut out = format!(
"{MARKER} --insights from the Fairlead config.\n# Run `fairlead ci workflow --insights --write` again rather than editing it.\nname: fairlead insights\n\non:\n schedule:\n - cron: \"17 5 * * 1\" # Mondays, 05:17 UTC\n workflow_dispatch:\n\npermissions:\n contents: read\n actions: read # the test workflow's runs and logs, for replay\n pull-requests: read # the pull request each run belongs to\n checks: read # the annotations that name a failing test\n\njobs:\n insights:\n name: insights\n runs-on: ubuntu-latest\n steps:\n - uses: {CHECKOUT}\n with:\n fetch-depth: 0\n"
);
install(&mut out);
out.push_str(&format!(
" - name: Replay the last 30 days\n env:\n GITHUB_TOKEN: ${{{{ github.token }}}}\n run: |\n since=$(date -u -d '30 days ago' +%F)\n fairlead replay fetch --repo \"$GITHUB_REPOSITORY\" --workflow fairlead.yml --data \"$RUNNER_TEMP/runs.jsonl\" --clone . --since \"$since\"\n fairlead replay run --data \"$RUNNER_TEMP/runs.jsonl\" --clone . --config {config} --json-out \"$RUNNER_TEMP/replay.json\" --quiet > /dev/null\n - name: Insights\n run: |\n {{\n echo '## Fairlead insights'\n echo '```'\n fairlead insights --since \"$(date -u -d '7 days ago' +%F)\" --replay \"$RUNNER_TEMP/replay.json\" --suggest\n echo '```'\n }} >> \"$GITHUB_STEP_SUMMARY\"\n"
));
out
}
/// The `if:` lines for a workflow a team wrote itself, one per gated id.
fn hints(gated: &[Gated]) -> String {
if gated.is_empty() {
return "fairlead: no runner or check waits for the merge stage, so no job needs an `if:`\n"
.into();
}
let mut out = String::from(
"fairlead: in a workflow you wrote, give each job `needs: plan` (the job running `fairlead ci plan --format github`, exporting its outputs) and its line:\n",
);
for g in gated {
let _ = writeln!(
out,
" if: needs.plan.outputs.{} == 'true' # {}",
g.output, g.id
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_job_id_is_one_github_accepts() {
assert_eq!(job_id("desktop-build"), "desktop-build");
assert_eq!(job_id("e2e.web"), "e2e_web");
assert_eq!(job_id("2e"), "_2e");
}
#[test]
fn a_branch_glob_is_quoted_and_a_name_is_not() {
assert_eq!(scalar("main"), "main");
assert_eq!(scalar("release/*"), "'release/*'");
assert_eq!(scalar("it's"), "'it''s'");
assert_eq!(scalar("on"), "'on'");
assert_eq!(scalar("2026"), "'2026'");
}
}