Files
MetaCrate/tools/ci-matrix/src/lib.rs
Chili Palmer cb0e039e9f
Some checks failed
CI / required (push) Failing after 15m5s
Consolidate required CI gate (#115)
2026-08-12 19:51:25 +00:00

661 lines
21 KiB
Rust

//! Validated, shell-free execution of the release CI matrix.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
mod api_surface;
mod artifact;
mod ci_gate;
mod dependency;
mod documentation;
mod provenance;
mod release_candidate;
pub use api_surface::{audit_api_surface, write_api_baseline};
pub use artifact::audit_artifacts;
pub use ci_gate::{audit_consolidated_ci, run_release_gate, run_required_gate};
pub use dependency::audit_dependencies;
pub use documentation::{audit_documentation, write_documentation_report};
pub use provenance::{audit_provenance, write_provenance_reports};
pub use release_candidate::audit_release_candidate;
pub const MATRIX_PATH: &str = "ci/release-matrix.json";
const WORKFLOW_PATH: &str = ".gitea/workflows/release.yml";
const REQUIRED_PROFILES: [&str; 7] = [
"linux-msrv-portable",
"linux-stable-default",
"linux-stable-minimal",
"linux-stable-features",
"linux-stable-release-surface",
"windows-stable-portable",
"macos-stable-portable",
];
const REQUIRED_FEATURE_SETS: [&str; 11] = [
"all-features",
"dds-bc67",
"default",
"docs",
"examples",
"jpeg2000",
"no-default-features",
"real-audio",
"skia",
"tests",
"vorbis",
];
const REQUIRED_FEATURES: [(&str, &str); 6] = [
("libremetaverse", "dds-bc67"),
("libremetaverse", "jpeg2000"),
("libremetaverse", "vorbis"),
("libremetaverse-imaging", "jpeg2000"),
("libremetaverse-imaging-skia", "skia"),
("libremetaverse-voice-webrtc", "real-audio"),
];
#[derive(Debug)]
pub struct MatrixError(String);
impl MatrixError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl fmt::Display for MatrixError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for MatrixError {}
impl From<std::io::Error> for MatrixError {
fn from(error: std::io::Error) -> Self {
Self::new(error.to_string())
}
}
impl From<serde_json::Error> for MatrixError {
fn from(error: serde_json::Error) -> Self {
Self::new(error.to_string())
}
}
pub type Result<T> = std::result::Result<T, MatrixError>;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReleaseMatrix {
pub schema: u32,
pub msrv: String,
pub current: String,
pub native_prerequisites: Vec<NativePrerequisite>,
pub manual_gates: Vec<String>,
pub profiles: Vec<Profile>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct NativePrerequisite {
pub id: String,
pub version: String,
pub purpose: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Profile {
pub id: String,
pub platform: String,
pub host: String,
pub execution: String,
pub toolchain: String,
pub target: String,
pub feature_sets: Vec<String>,
pub prerequisites: Vec<String>,
pub commands: Vec<CargoInvocation>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CargoInvocation {
pub label: String,
pub args: Vec<String>,
}
#[derive(Debug, Serialize)]
struct Evidence<'a> {
schema: u32,
profile: &'a str,
platform: &'a str,
host: &'a str,
execution: &'a str,
target: &'a str,
requested_toolchain: &'a str,
rustc: String,
cargo: String,
source_commit: String,
recorded_unix_seconds: u64,
feature_sets: &'a [String],
native_prerequisites: Vec<&'a NativePrerequisite>,
commands: &'a [CargoInvocation],
completed_commands: usize,
status: &'static str,
}
#[must_use]
pub fn workspace_root(start: &Path) -> Option<PathBuf> {
start.ancestors().find_map(|directory| {
let manifest = directory.join("Cargo.toml");
fs::read_to_string(&manifest)
.ok()
.filter(|contents| contents.contains("[workspace]"))
.map(|_| directory.to_path_buf())
})
}
/// Loads the canonical release matrix from a workspace root.
///
/// # Errors
///
/// Returns an error when the matrix cannot be read or is not valid JSON.
pub fn load(root: &Path) -> Result<ReleaseMatrix> {
let bytes = fs::read(root.join(MATRIX_PATH))?;
Ok(serde_json::from_slice(&bytes)?)
}
/// Validates the matrix, Cargo feature declarations, and Gitea workflow coverage.
///
/// # Errors
///
/// Returns an error when any release invariant is missing or inconsistent.
pub fn audit(root: &Path, matrix: &ReleaseMatrix) -> Result<()> {
audit_shape(matrix)?;
audit_cargo_features(root)?;
audit_workflows(root, matrix)?;
Ok(())
}
/// Validates the platform, toolchain, feature, and command shape of a matrix.
///
/// # Errors
///
/// Returns an error when a required profile, prerequisite, or gate is absent.
pub fn audit_shape(matrix: &ReleaseMatrix) -> Result<()> {
if matrix.schema != 1 {
return Err(MatrixError::new("release matrix schema must be 1"));
}
if matrix.msrv != "1.96.0" || matrix.current != "stable" {
return Err(MatrixError::new(
"release matrix must cover Rust 1.96.0 and current stable",
));
}
let prerequisite_ids = unique(
matrix
.native_prerequisites
.iter()
.map(|prerequisite| prerequisite.id.as_str()),
"native prerequisite",
)?;
if matrix.native_prerequisites.iter().any(|prerequisite| {
prerequisite.version.trim().is_empty() || prerequisite.purpose.trim().is_empty()
}) {
return Err(MatrixError::new(
"native prerequisites require exact versions and purposes",
));
}
let manual = matrix.manual_gates.join(" ").to_ascii_lowercase();
for boundary in ["live grid", "physical audio", "proprietary"] {
if !manual.contains(boundary) {
return Err(MatrixError::new(format!(
"manual gate boundary is missing {boundary}"
)));
}
}
let profile_ids = unique(
matrix.profiles.iter().map(|profile| profile.id.as_str()),
"profile",
)?;
for required in REQUIRED_PROFILES {
if !profile_ids.contains(required) {
return Err(MatrixError::new(format!(
"required profile {required} is missing"
)));
}
}
let (platforms, toolchains, feature_sets) = audit_profiles(matrix, &prerequisite_ids)?;
audit_release_surface(matrix)?;
if platforms != BTreeSet::from(["linux", "macos", "windows"])
|| !toolchains.contains("stable")
|| !toolchains.contains("1.96.0")
{
return Err(MatrixError::new(
"matrix must cover Linux, macOS, Windows, MSRV, and stable",
));
}
for required in REQUIRED_FEATURE_SETS {
if !feature_sets.contains(required) {
return Err(MatrixError::new(format!(
"feature/test surface {required} is missing"
)));
}
}
Ok(())
}
fn audit_release_surface(matrix: &ReleaseMatrix) -> Result<()> {
let profile = matrix
.profiles
.iter()
.find(|profile| profile.id == "linux-stable-release-surface")
.ok_or_else(|| MatrixError::new("release-surface profile is missing"))?;
let builds_programs = profile.commands.iter().any(|invocation| {
invocation
.args
.first()
.is_some_and(|argument| argument == "build")
&& invocation
.args
.windows(2)
.any(|pair| pair == ["-p", "libremetaverse-programs"])
&& invocation.args.iter().any(|argument| argument == "--bins")
});
if !builds_programs {
return Err(MatrixError::new(
"release-surface profile must build the shipped example-program binaries",
));
}
Ok(())
}
fn audit_profiles<'a>(
matrix: &'a ReleaseMatrix,
prerequisite_ids: &BTreeSet<&str>,
) -> Result<(BTreeSet<&'a str>, BTreeSet<&'a str>, BTreeSet<&'a str>)> {
let mut platforms = BTreeSet::new();
let mut toolchains = BTreeSet::new();
let mut feature_sets = BTreeSet::new();
for profile in &matrix.profiles {
if profile.host != "ubuntu-latest" {
return Err(MatrixError::new(format!(
"profile {} violates the ubuntu-latest Gitea constraint",
profile.id
)));
}
if !matches!(profile.platform.as_str(), "linux" | "windows" | "macos") {
return Err(MatrixError::new(format!(
"profile {} has an unsupported platform",
profile.id
)));
}
let expected_execution = if profile.platform == "linux" {
"native"
} else {
"cross"
};
if profile.execution != expected_execution {
return Err(MatrixError::new(format!(
"profile {} must use {expected_execution} execution",
profile.id
)));
}
if profile.target.trim().is_empty() || profile.commands.is_empty() {
return Err(MatrixError::new(format!(
"profile {} needs a target and at least one command",
profile.id
)));
}
for prerequisite in &profile.prerequisites {
if !prerequisite_ids.contains(prerequisite.as_str()) {
return Err(MatrixError::new(format!(
"profile {} references unknown prerequisite {prerequisite}",
profile.id
)));
}
}
for invocation in &profile.commands {
audit_invocation(profile, invocation)?;
}
platforms.insert(profile.platform.as_str());
toolchains.insert(profile.toolchain.as_str());
feature_sets.extend(profile.feature_sets.iter().map(String::as_str));
}
Ok((platforms, toolchains, feature_sets))
}
fn unique<'a>(values: impl Iterator<Item = &'a str>, kind: &str) -> Result<BTreeSet<&'a str>> {
let mut unique = BTreeSet::new();
for value in values {
if value.trim().is_empty() || !unique.insert(value) {
return Err(MatrixError::new(format!(
"{kind} identifiers must be nonempty and unique"
)));
}
}
Ok(unique)
}
fn audit_invocation(profile: &Profile, invocation: &CargoInvocation) -> Result<()> {
let Some(subcommand) = invocation.args.first().map(String::as_str) else {
return Err(MatrixError::new(format!(
"profile {} contains an empty Cargo command",
profile.id
)));
};
if !matches!(subcommand, "check" | "test" | "doc" | "build") {
return Err(MatrixError::new(format!(
"profile {} uses disallowed Cargo subcommand {subcommand}",
profile.id
)));
}
if invocation.label.trim().is_empty()
|| !invocation
.args
.iter()
.any(|argument| argument == "--locked")
|| !invocation.args.windows(2).any(|pair| pair == ["-j", "1"])
{
return Err(MatrixError::new(format!(
"profile {} commands need labels, --locked, and -j 1",
profile.id
)));
}
if profile.execution == "cross"
&& !invocation
.args
.windows(2)
.any(|pair| pair == ["--target", profile.target.as_str()])
{
return Err(MatrixError::new(format!(
"cross profile {} command does not select its target",
profile.id
)));
}
Ok(())
}
fn audit_cargo_features(root: &Path) -> Result<()> {
let output = Command::new(cargo_program())
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(root)
.output()?;
if !output.status.success() {
return Err(MatrixError::new(
"cargo metadata failed during matrix audit",
));
}
let metadata: Value = serde_json::from_slice(&output.stdout)?;
let packages = metadata["packages"]
.as_array()
.ok_or_else(|| MatrixError::new("cargo metadata has no package array"))?;
let feature_map = packages
.iter()
.filter_map(|package| Some((package["name"].as_str()?, package["features"].as_object()?)))
.collect::<BTreeMap<_, _>>();
for (package, feature) in REQUIRED_FEATURES {
if !feature_map
.get(package)
.is_some_and(|features| features.contains_key(feature))
{
return Err(MatrixError::new(format!(
"Cargo feature {package}/{feature} is missing"
)));
}
}
let defaults = feature_map
.get("libremetaverse")
.and_then(|features| features.get("default"))
.and_then(Value::as_array)
.ok_or_else(|| MatrixError::new("libremetaverse default feature set is missing"))?;
if defaults.iter().any(|feature| {
matches!(
feature.as_str(),
Some("jpeg2000" | "vorbis" | "dep:vorbis_rs")
)
}) {
return Err(MatrixError::new(
"default consumers must not enable native OpenJPEG or Vorbis adapters",
));
}
let default_tree = Command::new(cargo_program())
.args([
"tree",
"--locked",
"-p",
"libremetaverse",
"--edges",
"normal,build",
"--prefix",
"none",
])
.current_dir(root)
.output()?;
if !default_tree.status.success() {
return Err(MatrixError::new(
"cargo tree failed during default dependency audit",
));
}
let default_tree = String::from_utf8_lossy(&default_tree.stdout);
for forbidden in [
"libremetaverse-openjpeg ",
"vorbis_rs ",
"ogg_next_sys ",
"aotuv_lancer_vorbis_sys ",
] {
if default_tree.lines().any(|line| line.starts_with(forbidden)) {
return Err(MatrixError::new(format!(
"default consumer dependency graph unexpectedly contains {forbidden}"
)));
}
}
Ok(())
}
fn audit_workflows(root: &Path, matrix: &ReleaseMatrix) -> Result<()> {
let workflow = fs::read_to_string(root.join(WORKFLOW_PATH))?;
if matrix.profiles.is_empty()
|| !workflow.contains("release-gate")
|| !workflow.contains("~/.cargo/registry")
|| !workflow.contains("~/.cargo/git")
{
return Err(MatrixError::new(
"release workflow must invoke the Rust gate and cache Cargo downloads",
));
}
audit_consolidated_ci(root)?;
let workflows = root.join(".gitea/workflows");
for entry in fs::read_dir(workflows)? {
let path = entry?.path();
if !matches!(
path.extension().and_then(OsStr::to_str),
Some("yml" | "yaml")
) {
continue;
}
let contents = fs::read_to_string(&path)?.to_ascii_lowercase();
if contents.contains("runs-on: windows") || contents.contains("runs-on: macos") {
return Err(MatrixError::new(format!(
"{} violates the ubuntu-only Gitea runner policy",
path.display()
)));
}
}
Ok(())
}
/// Executes one profile from an empty target directory and writes its evidence record.
///
/// # Errors
///
/// Returns an error when the profile is unknown, its toolchain is incorrect, the
/// target directory is not clean, a command fails, or evidence cannot be written.
pub fn run(root: &Path, matrix: &ReleaseMatrix, profile_id: &str, evidence: &Path) -> Result<()> {
audit(root, matrix)?;
let profile = matrix
.profiles
.iter()
.find(|profile| profile.id == profile_id)
.ok_or_else(|| MatrixError::new(format!("unknown CI profile {profile_id}")))?;
if evidence.exists() {
return Err(MatrixError::new(format!(
"{} already exists; preserve or remove it before running the profile",
evidence.display()
)));
}
let rustc = version("rustc")?;
let cargo = version(cargo_program())?;
if profile.toolchain != "stable"
&& rustc
.split_whitespace()
.nth(1)
.is_none_or(|version| version != profile.toolchain)
{
return Err(MatrixError::new(format!(
"profile {} requires rustc {}, found {rustc}",
profile.id, profile.toolchain
)));
}
let target_directory = root.join("target/ci").join(&profile.id);
if target_directory.exists() {
return Err(MatrixError::new(format!(
"{} already exists; clean it before rerunning so cache cannot mask the build",
target_directory.display()
)));
}
let source_commit = command_line("git", ["rev-parse", "--verify", "HEAD"], root)?;
let prerequisites = profile
.prerequisites
.iter()
.map(|id| {
matrix
.native_prerequisites
.iter()
.find(|prerequisite| prerequisite.id == *id)
.ok_or_else(|| MatrixError::new(format!("unknown prerequisite {id}")))
})
.collect::<Result<Vec<_>>>()?;
let mut completed = 0;
let mut failure = None;
for invocation in &profile.commands {
println!("ci-matrix {}: {}", profile.id, invocation.label);
let status = Command::new(cargo_program())
.args(&invocation.args)
.current_dir(root)
.env("CARGO_INCREMENTAL", "0")
.env("CARGO_TARGET_DIR", &target_directory)
.stdin(Stdio::null())
.status()?;
if status.success() {
completed += 1;
} else {
failure = Some(status);
break;
}
}
let status = if failure.is_none() { "ok" } else { "failed" };
write_evidence(
evidence,
&Evidence {
schema: 1,
profile: &profile.id,
platform: &profile.platform,
host: &profile.host,
execution: &profile.execution,
target: &profile.target,
requested_toolchain: &profile.toolchain,
rustc,
cargo,
source_commit,
recorded_unix_seconds: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| MatrixError::new("system clock predates Unix epoch"))?
.as_secs(),
feature_sets: &profile.feature_sets,
native_prerequisites: prerequisites,
commands: &profile.commands,
completed_commands: completed,
status,
},
)?;
if let Some(status) = failure {
return Err(MatrixError::new(format!(
"profile {} failed with {}",
profile.id,
describe_status(status)
)));
}
Ok(())
}
fn version(program: impl AsRef<OsStr>) -> Result<String> {
command_line(program, ["--version"], Path::new("."))
}
fn command_line(
program: impl AsRef<OsStr>,
args: impl IntoIterator<Item = impl AsRef<OsStr>>,
directory: &Path,
) -> Result<String> {
let output = Command::new(program)
.args(args)
.current_dir(directory)
.output()?;
if !output.status.success() {
return Err(MatrixError::new("version/provenance command failed"));
}
String::from_utf8(output.stdout)
.map(|value| value.trim().to_owned())
.map_err(|_| MatrixError::new("version/provenance output was not UTF-8"))
}
fn cargo_program() -> std::ffi::OsString {
std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
}
fn write_evidence(path: &Path, evidence: &Evidence<'_>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
serde_json::to_writer_pretty(&mut file, evidence)?;
file.write_all(b"\n")?;
file.sync_all()?;
Ok(())
}
fn describe_status(status: ExitStatus) -> String {
status.code().map_or_else(
|| "termination by signal".to_owned(),
|code| format!("exit {code}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checked_in_matrix_has_complete_shape() {
let matrix: ReleaseMatrix =
serde_json::from_str(include_str!("../../../ci/release-matrix.json")).unwrap();
audit_shape(&matrix).unwrap();
}
#[test]
fn runner_rejects_unknown_profiles_before_execution() {
let root = workspace_root(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
let matrix = load(&root).unwrap();
let evidence = std::env::temp_dir().join("metacrate-ci-matrix-unknown.json");
assert!(run(&root, &matrix, "not-a-profile", &evidence).is_err());
assert!(!evidence.exists());
}
}