666 lines
21 KiB
Rust
666 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; 13] = [
|
|
"all-features",
|
|
"dds-bc67",
|
|
"default",
|
|
"docs",
|
|
"examples",
|
|
"jpeg2000",
|
|
"no-default-features",
|
|
"real-audio",
|
|
"rust-j2k",
|
|
"rust-skia",
|
|
"skia",
|
|
"tests",
|
|
"vorbis",
|
|
];
|
|
const REQUIRED_FEATURES: [(&str, &str); 9] = [
|
|
("libremetaverse", "dds-bc67"),
|
|
("libremetaverse", "jpeg2000"),
|
|
("libremetaverse", "rust-j2k"),
|
|
("libremetaverse", "vorbis"),
|
|
("libremetaverse-imaging", "jpeg2000"),
|
|
("libremetaverse-imaging", "rust-j2k"),
|
|
("libremetaverse-imaging-skia", "skia"),
|
|
("libremetaverse-imaging-skia", "rust-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());
|
|
}
|
|
}
|