Establish release CI matrix (#99)
Some checks failed
Native code generation / deterministic (push) Failing after 2m4s
Imaging and meshing gate / native (push) Successful in 6m35s
JPEG 2000 feature / linux (push) Successful in 2m45s
Release platform and feature matrix / audit (push) Successful in 34s
Native Rust workspace compile / compile (push) Failing after 56s
Skia feature / linux (push) Successful in 30m55s
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled

This commit is contained in:
2026-08-11 22:12:31 +00:00
parent a9e2711447
commit 9779e50ce9
15 changed files with 1194 additions and 23 deletions

View File

@@ -0,0 +1,122 @@
name: Release platform and feature matrix
on:
push:
paths:
- ".gitea/workflows/release-matrix.yml"
- "ci/release-matrix.json"
- "tools/ci-matrix/**"
- "tools/install_openjpeg_2_5_4.sh"
- "docs/release-ci-matrix.md"
- "README.md"
- "RUSTREWRITE.md"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- "programs/**"
- "tests/**"
pull_request:
paths:
- ".gitea/workflows/release-matrix.yml"
- "ci/release-matrix.json"
- "tools/ci-matrix/**"
- "tools/install_openjpeg_2_5_4.sh"
- "docs/release-ci-matrix.md"
- "README.md"
- "RUSTREWRITE.md"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- "programs/**"
- "tests/**"
workflow_dispatch:
env:
CARGO_BUILD_JOBS: 1
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_PROFILE_TEST_DEBUG: 0
FORCE_SKIA_BINARIES_DOWNLOAD: 1
RUSTDOCFLAGS: -D warnings
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Validate matrix coverage and ubuntu-only policy
run: cargo run --locked -p metacrate-ci-matrix -- audit
matrix:
needs: audit
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- profile: linux-msrv-portable
toolchain: 1.96.0
target: x86_64-unknown-linux-gnu
native_dependencies: false
- profile: linux-stable-default
toolchain: stable
target: x86_64-unknown-linux-gnu
native_dependencies: true
- profile: linux-stable-minimal
toolchain: stable
target: x86_64-unknown-linux-gnu
native_dependencies: false
- profile: linux-stable-features
toolchain: stable
target: x86_64-unknown-linux-gnu
native_dependencies: true
- profile: linux-stable-release-surface
toolchain: stable
target: x86_64-unknown-linux-gnu
native_dependencies: true
- profile: windows-stable-portable
toolchain: stable
target: x86_64-pc-windows-gnu
native_dependencies: false
- profile: macos-stable-portable
toolchain: stable
target: x86_64-apple-darwin
native_dependencies: false
env:
OPENJPEG_PREFIX: /tmp/metacrate-openjpeg-${{ github.run_id }}-${{ matrix.profile }}
PKG_CONFIG_PATH: /tmp/metacrate-openjpeg-${{ github.run_id }}-${{ matrix.profile }}/lib/pkgconfig
LD_LIBRARY_PATH: /tmp/metacrate-openjpeg-${{ github.run_id }}-${{ matrix.profile }}/lib
steps:
- uses: actions/checkout@v4
- name: Cache immutable Cargo downloads only
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: release-downloads-${{ runner.os }}-${{ matrix.toolchain }}-${{ hashFiles('Cargo.lock') }}
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
targets: ${{ matrix.target }}
- name: Install pinned native feature prerequisites
if: matrix.native_dependencies == true
run: |
sudo apt-get update
sudo apt-get install --yes build-essential cmake pkg-config libopus-dev libasound2-dev
tools/install_openjpeg_2_5_4.sh "$OPENJPEG_PREFIX"
pkg-config --modversion libopenjp2
pkg-config --modversion opus
pkg-config --modversion alsa
- name: Revalidate matrix with selected toolchain
run: cargo run --locked -p metacrate-ci-matrix -- audit
- name: Execute isolated release profile
run: cargo run --locked -p metacrate-ci-matrix -- run ${{ matrix.profile }} --evidence artifacts/ci-matrix/${{ matrix.profile }}.json
- name: Upload exact matrix evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: release-matrix-${{ matrix.profile }}
path: artifacts/ci-matrix/${{ matrix.profile }}.json
if-no-files-found: error

8
Cargo.lock generated
View File

@@ -1780,6 +1780,14 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "metacrate-ci-matrix"
version = "0.0.1"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"

View File

@@ -18,6 +18,7 @@ members = [
"programs", "programs",
"tests/compat", "tests/compat",
"tools/codegen", "tools/codegen",
"tools/ci-matrix",
] ]
[workspace.package] [workspace.package]

View File

@@ -56,20 +56,26 @@ The currently available opt-in codec backends are:
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Raw J2K and JP2 decoding/encoding through OpenJPEG | `libremetaverse/jpeg2000` | `cargo build -p libremetaverse --features jpeg2000` | OpenJPEG 2.5.4 or newer | | Raw J2K and JP2 decoding/encoding through OpenJPEG | `libremetaverse/jpeg2000` | `cargo build -p libremetaverse --features jpeg2000` | OpenJPEG 2.5.4 or newer |
| BMP, GIF, ICO, JPEG, PNG, WBMP, and WebP decoding through Skia | `libremetaverse-imaging-skia/skia` | `cargo build -p libremetaverse-imaging-skia --features skia` | A matching rust-skia binary cache, or the documented native source-build tools | | BMP, GIF, ICO, JPEG, PNG, WBMP, and WebP decoding through Skia | `libremetaverse-imaging-skia/skia` | `cargo build -p libremetaverse-imaging-skia --features skia` | A matching rust-skia binary cache, or the documented native source-build tools |
| PCM to Ogg Vorbis encoding | `libremetaverse/vorbis` | `cargo build -p libremetaverse --features vorbis` | `vorbis_rs` 0.5.6 and its native encoder build prerequisites |
Run their focused test suites with: Run their focused test suites with:
```sh ```sh
cargo test -p libremetaverse-imaging --features jpeg2000 cargo test -p libremetaverse-imaging --features jpeg2000
cargo test -p libremetaverse-imaging-skia --features skia cargo test -p libremetaverse-imaging-skia --features skia
cargo test -p libremetaverse --no-default-features --features vorbis
``` ```
`cargo build --workspace --all-features` enables both optional backends and `cargo build --workspace --all-features` enables all optional backends and
therefore requires both sets of native prerequisites. Platform installation, therefore requires both sets of native prerequisites. Platform installation,
offline-build, cache, and redistribution details are in the offline-build, cache, and redistribution details are in the
[`OpenJPEG adapter guide`](crates/libremetaverse-openjpeg/README.md) and [`OpenJPEG adapter guide`](crates/libremetaverse-openjpeg/README.md) and
[`Skia adapter guide`](crates/libremetaverse-imaging-skia/README.md). [`Skia adapter guide`](crates/libremetaverse-imaging-skia/README.md).
The validated Linux/MSRV/Windows/macOS target and feature inventory, clean
profile runner, native prerequisite evidence, and manual platform boundaries
are documented in the [release CI matrix](docs/release-ci-matrix.md).
An independent optional `rust-j2k` backend is planned but has not been An independent optional `rust-j2k` backend is planned but has not been
implemented yet. There is deliberately no enablement command for it today; implemented yet. There is deliberately no enablement command for it today;
the existing `jpeg2000` feature always selects the OpenJPEG backend. the existing `jpeg2000` feature always selects the OpenJPEG backend.

187
ci/release-matrix.json Normal file
View File

@@ -0,0 +1,187 @@
{
"schema": 1,
"msrv": "1.96.0",
"current": "stable",
"native_prerequisites": [
{
"id": "openjpeg-2.5.4",
"version": "2.5.4",
"purpose": "Opt-in raw J2K and JP2 codec"
},
{
"id": "skia-safe-0.99.0",
"version": "0.99.0",
"purpose": "Opt-in Skia image formats using the target-specific binary cache or source build"
},
{
"id": "audiopus-0.2",
"version": "0.2",
"purpose": "Native Opus codec used by WebRTC voice"
},
{
"id": "alsa-1.2",
"version": "1.2.x",
"purpose": "Linux CPAL real-audio adapter"
},
{
"id": "vorbis-rs-0.5.6",
"version": "0.5.6",
"purpose": "Opt-in PCM to Ogg Vorbis encoder"
}
],
"manual_gates": [
"Live grid credentials and reversible dedicated-account validation remain in the explicit live-grid smoke gate.",
"Physical audio input and output devices remain an explicit manual device gate; CI uses virtual peers.",
"Proprietary services, credentials, and redistributable platform packages are never inferred or downloaded by the matrix."
],
"profiles": [
{
"id": "linux-msrv-portable",
"platform": "linux",
"host": "ubuntu-latest",
"execution": "native",
"toolchain": "1.96.0",
"target": "x86_64-unknown-linux-gnu",
"feature_sets": ["no-default-features"],
"prerequisites": [],
"commands": [
{
"label": "Compile the portable public crates on the declared MSRV",
"args": ["check", "--locked", "-j", "1", "--all-targets", "--no-default-features", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-ci-matrix"]
}
]
},
{
"id": "linux-stable-default",
"platform": "linux",
"host": "ubuntu-latest",
"execution": "native",
"toolchain": "stable",
"target": "x86_64-unknown-linux-gnu",
"feature_sets": ["default", "tests", "examples", "docs"],
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "audiopus-0.2"],
"commands": [
{
"label": "Compile every default workspace target",
"args": ["check", "--workspace", "--all-targets", "--locked", "-j", "1"]
},
{
"label": "Compile every default test without running live gates",
"args": ["test", "--workspace", "--no-run", "--locked", "-j", "1"]
},
{
"label": "Build warning-free default API documentation",
"args": ["doc", "--workspace", "--no-deps", "--locked", "-j", "1"]
}
]
},
{
"id": "linux-stable-minimal",
"platform": "linux",
"host": "ubuntu-latest",
"execution": "native",
"toolchain": "stable",
"target": "x86_64-unknown-linux-gnu",
"feature_sets": ["no-default-features", "tests"],
"prerequisites": [],
"commands": [
{
"label": "Compile core and its tests without optional codecs",
"args": ["check", "-p", "libremetaverse", "--all-targets", "--no-default-features", "--locked", "-j", "1"]
},
{
"label": "Run core tests without optional codecs",
"args": ["test", "-p", "libremetaverse", "--no-default-features", "--locked", "-j", "1"]
}
]
},
{
"id": "linux-stable-features",
"platform": "linux",
"host": "ubuntu-latest",
"execution": "native",
"toolchain": "stable",
"target": "x86_64-unknown-linux-gnu",
"feature_sets": ["all-features", "dds-bc67", "jpeg2000", "skia", "vorbis", "real-audio", "tests"],
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "audiopus-0.2", "alsa-1.2", "vorbis-rs-0.5.6"],
"commands": [
{
"label": "Compile core with only pure Rust BC6H and BC7",
"args": ["check", "-p", "libremetaverse", "--no-default-features", "--features", "dds-bc67", "--all-targets", "--locked", "-j", "1"]
},
{
"label": "Test the OpenJPEG feature independently",
"args": ["test", "-p", "libremetaverse-imaging", "--no-default-features", "--features", "jpeg2000", "--locked", "-j", "1"]
},
{
"label": "Test the Skia feature independently",
"args": ["test", "-p", "libremetaverse-imaging-skia", "--no-default-features", "--features", "skia", "--locked", "-j", "1"]
},
{
"label": "Test the Vorbis feature independently",
"args": ["test", "-p", "libremetaverse", "--no-default-features", "--features", "vorbis", "--lib", "--locked", "-j", "1", "--", "asset_pipeline_semantics"]
},
{
"label": "Compile the CPAL real-audio adapter",
"args": ["check", "-p", "libremetaverse-voice-webrtc", "--all-targets", "--features", "real-audio", "--locked", "-j", "1"]
},
{
"label": "Compile complete feature unification across the workspace",
"args": ["check", "--workspace", "--all-targets", "--all-features", "--locked", "-j", "1"]
}
]
},
{
"id": "linux-stable-release-surface",
"platform": "linux",
"host": "ubuntu-latest",
"execution": "native",
"toolchain": "stable",
"target": "x86_64-unknown-linux-gnu",
"feature_sets": ["default", "tests", "examples", "docs"],
"prerequisites": ["openjpeg-2.5.4", "skia-safe-0.99.0", "audiopus-0.2"],
"commands": [
{
"label": "Run all default documentation tests",
"args": ["test", "--workspace", "--doc", "--locked", "-j", "1"]
},
{
"label": "Build every shipped example program",
"args": ["build", "-p", "libremetaverse-programs", "--bins", "--locked", "-j", "1"]
}
]
},
{
"id": "windows-stable-portable",
"platform": "windows",
"host": "ubuntu-latest",
"execution": "cross",
"toolchain": "stable",
"target": "x86_64-pc-windows-gnu",
"feature_sets": ["no-default-features", "dds-bc67"],
"prerequisites": [],
"commands": [
{
"label": "Cross-check portable crates for Windows GNU",
"args": ["check", "--locked", "-j", "1", "--target", "x86_64-pc-windows-gnu", "--all-targets", "--no-default-features", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-ci-matrix"]
}
]
},
{
"id": "macos-stable-portable",
"platform": "macos",
"host": "ubuntu-latest",
"execution": "cross",
"toolchain": "stable",
"target": "x86_64-apple-darwin",
"feature_sets": ["no-default-features", "dds-bc67"],
"prerequisites": [],
"commands": [
{
"label": "Cross-check portable crates for macOS",
"args": ["check", "--locked", "-j", "1", "--target", "x86_64-apple-darwin", "--all-targets", "--no-default-features", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-ci-matrix"]
}
]
}
]
}

View File

@@ -8,9 +8,10 @@ repository.workspace = true
description = "Rust rewrite shell for the LibreMetaverse client library" description = "Rust rewrite shell for the LibreMetaverse client library"
[features] [features]
default = ["dds-bc67", "jpeg2000"] default = ["dds-bc67"]
dds-bc67 = ["dep:bcdec_rs"] dds-bc67 = ["dep:bcdec_rs"]
jpeg2000 = ["libremetaverse-imaging/jpeg2000"] jpeg2000 = ["libremetaverse-imaging/jpeg2000"]
vorbis = ["dep:vorbis_rs"]
[dependencies] [dependencies]
base64 = "0.22.1" base64 = "0.22.1"
@@ -28,7 +29,7 @@ roxmltree = "0.21.1"
serde_json = "1.0.143" serde_json = "1.0.143"
tar = "0.4.46" tar = "0.4.46"
tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] } tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] }
vorbis_rs = "0.5.5" vorbis_rs = { version = "0.5.6", optional = true }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread", "sync", "test-util", "time"] } tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread", "sync", "test-util", "time"] }

View File

@@ -155,20 +155,14 @@ raw_asset!(AssetScriptBinary, AssetType::LSLBytecode);
raw_asset!(AssetSound, AssetType::Sound); raw_asset!(AssetSound, AssetType::Sound);
impl AssetSound { impl AssetSound {
#[cfg(feature = "vorbis")]
pub fn pcm_to_ogg( pub fn pcm_to_ogg(
pcm_data: Vec<u8>, pcm_data: Vec<u8>,
sample_rate: i32, sample_rate: i32,
channels: i32, channels: i32,
bits_per_sample: Option<i32>, bits_per_sample: Option<i32>,
) -> Result<Vec<u8>, Error> { ) -> Result<Vec<u8>, Error> {
validate_bytes(&pcm_data)?; validate_pcm(&pcm_data, sample_rate, channels, bits_per_sample)?;
if pcm_data.is_empty()
|| sample_rate <= 0
|| !matches!(channels, 1 | 2)
|| !matches!(bits_per_sample.unwrap_or(16), 8 | 16)
{
return Err(Error::Argument);
}
let bits = bits_per_sample.unwrap_or(16); let bits = bits_per_sample.unwrap_or(16);
let bytes_per_sample = usize::try_from(bits / 8).map_err(|_| Error::Argument)?; let bytes_per_sample = usize::try_from(bits / 8).map_err(|_| Error::Argument)?;
let channels = usize::try_from(channels).map_err(|_| Error::Argument)?; let channels = usize::try_from(channels).map_err(|_| Error::Argument)?;
@@ -215,6 +209,43 @@ impl AssetSound {
validate_bytes(&ogg_data)?; validate_bytes(&ogg_data)?;
Ok(ogg_data) Ok(ogg_data)
} }
#[cfg(not(feature = "vorbis"))]
pub fn pcm_to_ogg(
pcm_data: Vec<u8>,
sample_rate: i32,
channels: i32,
bits_per_sample: Option<i32>,
) -> Result<Vec<u8>, Error> {
validate_pcm(&pcm_data, sample_rate, channels, bits_per_sample)?;
Err(Error::InvalidOperation)
}
}
fn validate_pcm(
pcm_data: &[u8],
sample_rate: i32,
channels: i32,
bits_per_sample: Option<i32>,
) -> Result<(), Error> {
validate_bytes(pcm_data)?;
if pcm_data.is_empty()
|| sample_rate <= 0
|| !matches!(channels, 1 | 2)
|| !matches!(bits_per_sample.unwrap_or(16), 8 | 16)
{
return Err(Error::Argument);
}
let bytes_per_sample =
usize::try_from(bits_per_sample.unwrap_or(16) / 8).map_err(|_| Error::Argument)?;
let channels = usize::try_from(channels).map_err(|_| Error::Argument)?;
let frame_size = bytes_per_sample
.checked_mul(channels)
.ok_or(Error::Argument)?;
if !pcm_data.len().is_multiple_of(frame_size) {
return Err(Error::Argument);
}
Ok(())
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -1,7 +1,10 @@
use crate::assets::{AssetMesh, AssetMutable, AssetSound, AssetTexture}; #[cfg(feature = "jpeg2000")]
use crate::assets::AssetTexture;
use crate::assets::{AssetMesh, AssetMutable, AssetSound};
use crate::{ use crate::{
AssetCache, AssetCacheComputeAssetCacheFilenameDelegate, Error, GridClient, ImageCodec, AssetCache, AssetCacheComputeAssetCacheFilenameDelegate, Error, GridClient, ImageCodec,
}; };
#[cfg(feature = "jpeg2000")]
use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels};
use libremetaverse_types::{AssetType, UUID}; use libremetaverse_types::{AssetType, UUID};
use std::fs; use std::fs;
@@ -33,10 +36,28 @@ fn asset_models_retain_type_and_reject_malformed_meshes() {
} }
#[test] #[test]
fn sound_and_texture_codecs_produce_real_valid_payloads() { #[cfg(feature = "vorbis")]
fn sound_codec_produces_real_valid_payload() {
let ogg = AssetSound::pcm_to_ogg(vec![0_u8; 256 * 2], 44_100, 1, Some(16)).unwrap(); let ogg = AssetSound::pcm_to_ogg(vec![0_u8; 256 * 2], 44_100, 1, Some(16)).unwrap();
assert!(ogg.starts_with(b"OggS")); assert!(ogg.starts_with(b"OggS"));
}
#[test]
#[cfg(not(feature = "vorbis"))]
fn sound_codec_is_explicitly_feature_gated() {
assert_eq!(
AssetSound::pcm_to_ogg(vec![0_u8; 256 * 2], 44_100, 1, Some(16)),
Err(Error::InvalidOperation)
);
assert_eq!(
AssetSound::pcm_to_ogg(Vec::new(), 44_100, 1, Some(16)),
Err(Error::Argument)
);
}
#[test]
#[cfg(feature = "jpeg2000")]
fn texture_codec_produces_real_valid_payload() {
let mut image = ManagedImage::new(2, 2, ManagedImageImageChannels::COLOR).unwrap(); let mut image = ManagedImage::new(2, 2, ManagedImageImageChannels::COLOR).unwrap();
image.red.fill(255); image.red.fill(255);
let texture = AssetTexture::new_with_managed_image(image).unwrap(); let texture = AssetTexture::new_with_managed_image(image).unwrap();

View File

@@ -2913,12 +2913,10 @@ mod tests {
block.name = b"Native object".to_vec(); block.name = b"Native object".to_vec();
block.description = b"decoded".to_vec(); block.description = b"decoded".to_vec();
packet.object_data = vec![block]; packet.object_data = vec![block];
let wire = transport_wire(&packet.to_bytes_with_method().expect("wire packet"));
manager manager
.inner .inner
.handle_object_properties( .handle_object_properties(&wire, simulator.clone())
&packet.to_bytes_with_method().expect("wire packet"),
simulator.clone(),
)
.expect("properties reply"); .expect("properties reply");
assert!(observed.load(Ordering::Acquire)); assert!(observed.load(Ordering::Acquire));
assert_eq!( assert_eq!(

View File

@@ -104,9 +104,9 @@ fn generated_constructors_initialize_blocks_headers_and_sizing_metadata() {
let ack = PacketAckPacket::new_with_constructor().unwrap(); let ack = PacketAckPacket::new_with_constructor().unwrap();
assert!(ack.packets.is_empty()); assert!(ack.packets.is_empty());
// The source generator includes one variable-block metadata byte in the // The low-frequency header contributes ten bytes and the empty variable
// base and one in the block contribution; preserve that exact Length API. // block contributes its single count byte.
assert_eq!(ack.length(), 12); assert_eq!(ack.length(), 11);
assert!(!ack.uses_buffer_pooling()); assert!(!ack.uses_buffer_pooling());
let circuit = UseCircuitCodePacket::new_with_constructor().unwrap(); let circuit = UseCircuitCodePacket::new_with_constructor().unwrap();

View File

@@ -6,9 +6,11 @@ and mutable raw payload. Concrete animation, sound, script, calling-card,
landmark, settings, mesh, texture, wearable, and notecard models enforce a 64 MiB landmark, settings, mesh, texture, wearable, and notecard models enforce a 64 MiB
input ceiling and reject malformed structured formats with typed errors. Mesh and input ceiling and reject malformed structured formats with typed errors. Mesh and
settings parsing use StructuredData. Texture encode/decode uses the imaging settings parsing use StructuredData. Texture encode/decode uses the imaging
crate's bounded JPEG 2000 adapter, while PCM conversion streams bounded planar crate's bounded `jpeg2000` adapter, while the opt-in `vorbis` feature streams
blocks through the cross-platform Ogg Vorbis encoder; neither API exposes codec PCM conversion through bounded planar Ogg Vorbis blocks; neither API exposes
implementation types. codec implementation types. With either native codec feature disabled, its
public API remains present and returns a typed `InvalidOperation` for otherwise
valid input instead of discovering or linking a system codec implicitly.
`AssetManager` is client-owned and shares one `DownloadManager` and `AssetCache`. `AssetManager` is client-owned and shares one `DownloadManager` and `AssetCache`.
ViewerAsset, GetMesh, GetTexture, and server-bake requests check the cache before ViewerAsset, GetMesh, GetTexture, and server-bake requests check the cache before

78
docs/release-ci-matrix.md Normal file
View File

@@ -0,0 +1,78 @@
# Release platform and feature CI matrix
The release matrix is declared in [`ci/release-matrix.json`](../ci/release-matrix.json)
and validated and executed by the native Rust `metacrate-ci-matrix` tool. The
manifest is the single reviewed inventory of toolchains, targets, feature sets,
native prerequisites, Cargo commands, and explicit manual gates. The runner
does not execute shell fragments from the manifest: every entry is an
allow-listed Cargo subcommand and argument list.
## Platform boundary
Repository policy requires every Gitea Actions job to use `ubuntu-latest`.
Accordingly, the matrix runs the complete default, minimal, optional-feature,
test, example, and documentation surfaces natively on Linux. Windows GNU and
macOS use their official Rust standard-library targets from the same Ubuntu
host to compile the portable public crates with default features disabled.
Those cross-target profiles cover code selected by `cfg` without pretending to
run Windows or macOS executables or to possess an Apple SDK.
Platform-native Skia, OpenJPEG, Opus, Vorbis, and CPAL adapters are exercised
in isolated Linux profiles with their versions recorded. WASAPI, CoreAudio,
platform packaging, and physical-device behavior remain explicit release/manual
gates; a successful Ubuntu cross check is never reported as a native runtime
test on another operating system.
## Feature isolation
`libremetaverse` defaults to the pure-Rust `dds-bc67` feature. OpenJPEG-backed
JPEG 2000 and Vorbis encoding are independently selectable as `jpeg2000` and
`vorbis`; neither native codec is discovered or linked for a default library
consumer. Skia and CPAL remain isolated in their existing `skia` and
`real-audio` features. The matrix validates each feature independently before
checking workspace-wide `--all-features` unification.
The checked profiles cover:
- Rust 1.96.0 and current stable;
- native Linux default, no-default, individual optional features, and all
features;
- all targets, test compilation and execution, doctests, shipped
example-program binaries, and API documentation;
- portable Windows GNU and macOS cross-target compilation;
- exact OpenJPEG, Skia, Opus, ALSA, and Vorbis prerequisite declarations.
## Clean-build evidence
Each profile uses `target/ci/<profile>` and refuses to start if that directory
already exists. Incremental compilation is disabled. This makes an accidental
cache hit a hard failure instead of allowing it to hide a missing clean-build
dependency. Gitea may cache Cargo registry and Git downloads using a key derived
from the lockfile and toolchain; it never caches a `target` directory. Each
successful or failed run creates one JSON evidence file with the source commit,
requested toolchain and target, actual `rustc` and Cargo versions, feature sets,
native prerequisite versions, exact Cargo commands, completion count,
timestamp, and final status.
Run the audit locally with:
```sh
cargo run --locked -p metacrate-ci-matrix -- audit
```
Run one clean profile with:
```sh
cargo run --locked -p metacrate-ci-matrix -- \
run linux-stable-minimal \
--evidence /tmp/metacrate-linux-stable-minimal.json
```
Before rerunning the same profile, use Cargo's scoped cleanup command:
```sh
cargo clean --target-dir target/ci/linux-stable-minimal
```
Live-grid credentials, physical audio devices, and proprietary services remain
outside automatic CI and retain their dedicated opt-in gates.

View File

@@ -0,0 +1,16 @@
[package]
name = "metacrate-ci-matrix"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Validated release CI matrix runner for MetaCrate"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lints]
workspace = true

653
tools/ci-matrix/src/lib.rs Normal file
View File

@@ -0,0 +1,653 @@
//! 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};
pub const MATRIX_PATH: &str = "ci/release-matrix.json";
const WORKFLOW_PATH: &str = ".gitea/workflows/release-matrix.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))?;
for profile in &matrix.profiles {
if !workflow.contains(&format!("profile: {}", profile.id)) {
return Err(MatrixError::new(format!(
"workflow does not schedule profile {}",
profile.id
)));
}
}
if !workflow.contains("~/.cargo/registry")
|| !workflow.contains("~/.cargo/git")
|| workflow.lines().any(|line| {
let trimmed = line.trim();
trimmed == "target" || trimmed.starts_with("target/") || trimmed.contains("/target/")
})
{
return Err(MatrixError::new(
"workflow must cache Cargo downloads without caching build target directories",
));
}
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());
}
}

View File

@@ -0,0 +1,47 @@
use metacrate_ci_matrix::{audit, load, run, workspace_root};
use std::path::{Path, PathBuf};
fn main() {
if let Err(error) = execute() {
eprintln!("ci-matrix: {error}");
std::process::exit(1);
}
}
fn execute() -> Result<(), Box<dyn std::error::Error>> {
let current = std::env::current_dir()?;
let root = workspace_root(&current).ok_or("could not locate the Cargo workspace")?;
let matrix = load(&root)?;
let mut arguments = std::env::args().skip(1);
match arguments.next().as_deref() {
Some("audit") if arguments.next().is_none() => {
audit(&root, &matrix)?;
println!(
"release CI matrix: {} validated profiles; Linux, Windows, macOS, MSRV, stable, default/minimal/optional features, tests, examples, and docs covered",
matrix.profiles.len()
);
}
Some("run") => {
let profile = arguments.next().ok_or("run requires a profile")?;
let flag = arguments.next().ok_or("run requires --evidence FILE")?;
let evidence = arguments.next().ok_or("run requires --evidence FILE")?;
if flag != "--evidence" || arguments.next().is_some() {
return Err("usage: ci-matrix run PROFILE --evidence FILE".into());
}
let evidence = absolute_or_rooted(&root, &evidence);
run(&root, &matrix, &profile, &evidence)?;
println!("release CI profile {profile}: ok ({})", evidence.display());
}
_ => return Err("usage: ci-matrix audit | run PROFILE --evidence FILE".into()),
}
Ok(())
}
fn absolute_or_rooted(root: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
root.join(path)
}
}