api_qualification.rs (5576B)
1 use std::{collections::BTreeSet, fs, path::Path, process::Command}; 2 3 use serde::Deserialize; 4 5 #[derive(Debug, Deserialize)] 6 struct Contract { 7 schema_version: u16, 8 baseline_revision: String, 9 tool: String, 10 minimum_tool_version: String, 11 release_type: String, 12 feature_policy: String, 13 packages: Vec<String>, 14 baseline_override: Vec<BaselineOverride>, 15 } 16 17 #[derive(Debug, Deserialize)] 18 struct BaselineOverride { 19 package: String, 20 revision: String, 21 } 22 23 pub fn run(root: &Path) -> Result<(), String> { 24 let contract = load(root)?; 25 validate(&contract, 19)?; 26 verify_tool(&contract)?; 27 verify_revision(root, &contract.baseline_revision)?; 28 for baseline in &contract.baseline_override { 29 verify_revision(root, &baseline.revision)?; 30 } 31 for package in &contract.packages { 32 let args = invocation(&contract, package); 33 eprintln!("cargo {}", args.join(" ")); 34 let status = Command::new("cargo") 35 .args(&args) 36 .env("CARGO_PROFILE_DEV_DEBUG", "none") 37 .current_dir(root) 38 .status() 39 .map_err(|error| format!("failed to start cargo-semver-checks: {error}"))?; 40 if !status.success() { 41 return Err(format!("public API qualification failed for {package}")); 42 } 43 } 44 Ok(()) 45 } 46 47 fn load(root: &Path) -> Result<Contract, String> { 48 let path = root.join("contracts/releases/api_semver.toml"); 49 let raw = fs::read_to_string(&path) 50 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 51 toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) 52 } 53 54 fn validate(contract: &Contract, expected_packages: usize) -> Result<(), String> { 55 if contract.schema_version != 1 56 || contract.tool != "cargo-semver-checks" 57 || contract.release_type != "major" 58 || contract.feature_policy != "all" 59 || contract.baseline_revision.len() < 7 60 { 61 return Err("invalid public API qualification contract".to_owned()); 62 } 63 let unique = contract.packages.iter().collect::<BTreeSet<_>>(); 64 if unique.len() != expected_packages || unique.len() != contract.packages.len() { 65 return Err(format!( 66 "public API contract requires exactly {expected_packages} unique packages" 67 )); 68 } 69 let overrides = contract 70 .baseline_override 71 .iter() 72 .map(|baseline| baseline.package.as_str()) 73 .collect::<BTreeSet<_>>(); 74 if overrides != BTreeSet::from(["radroots", "radroots_sdk"]) 75 || contract 76 .baseline_override 77 .iter() 78 .any(|baseline| baseline.revision.len() != 40) 79 { 80 return Err( 81 "public API baseline overrides must identify the two imported front doors".to_owned(), 82 ); 83 } 84 Ok(()) 85 } 86 87 fn verify_tool(contract: &Contract) -> Result<(), String> { 88 let output = Command::new("cargo") 89 .args(["semver-checks", "--version"]) 90 .output() 91 .map_err(|error| format!("failed to start cargo-semver-checks: {error}"))?; 92 if !output.status.success() { 93 return Err("cargo-semver-checks is required".to_owned()); 94 } 95 let stdout = String::from_utf8_lossy(&output.stdout); 96 let installed = stdout 97 .split_whitespace() 98 .find_map(|value| semver::Version::parse(value).ok()) 99 .ok_or_else(|| format!("could not parse cargo-semver-checks version: {stdout}"))?; 100 let minimum = semver::Version::parse(&contract.minimum_tool_version) 101 .map_err(|error| format!("invalid minimum tool version: {error}"))?; 102 if installed < minimum { 103 return Err(format!( 104 "cargo-semver-checks {} or newer is required, found {installed}", 105 contract.minimum_tool_version 106 )); 107 } 108 Ok(()) 109 } 110 111 fn verify_revision(root: &Path, revision: &str) -> Result<(), String> { 112 let status = Command::new("git") 113 .args(["cat-file", "-e", &format!("{revision}^{{commit}}")]) 114 .current_dir(root) 115 .status() 116 .map_err(|error| format!("failed to inspect API baseline revision: {error}"))?; 117 if status.success() { 118 Ok(()) 119 } else { 120 Err(format!("API baseline revision {revision} is unavailable")) 121 } 122 } 123 124 fn invocation(contract: &Contract, package: &str) -> Vec<String> { 125 let baseline = contract 126 .baseline_override 127 .iter() 128 .find(|baseline| baseline.package == package) 129 .map_or(contract.baseline_revision.as_str(), |baseline| { 130 baseline.revision.as_str() 131 }); 132 vec![ 133 "semver-checks".to_owned(), 134 "check-release".to_owned(), 135 "--package".to_owned(), 136 package.to_owned(), 137 "--baseline-rev".to_owned(), 138 baseline.to_owned(), 139 "--all-features".to_owned(), 140 "--release-type".to_owned(), 141 contract.release_type.clone(), 142 ] 143 } 144 145 #[cfg(test)] 146 mod tests { 147 use super::{invocation, load, validate}; 148 149 #[test] 150 fn current_contract_covers_all_library_packages() { 151 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) 152 .parent() 153 .and_then(std::path::Path::parent) 154 .expect("workspace root"); 155 let contract = load(root).expect("contract"); 156 validate(&contract, 19).expect("valid contract"); 157 let invocation = invocation(&contract, "radroots_core"); 158 assert!(invocation.contains(&"--all-features".to_owned())); 159 assert!(invocation.ends_with(&["--release-type".to_owned(), "major".to_owned()])); 160 } 161 }