diff --git a/.gitea/workflows/release-matrix.yml b/.gitea/workflows/release-matrix.yml new file mode 100644 index 0000000..b989090 --- /dev/null +++ b/.gitea/workflows/release-matrix.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 847ef76..6756500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,6 +1780,14 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metacrate-ci-matrix" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "minimal-lexical" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 857e5d9..51a2aa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "programs", "tests/compat", "tools/codegen", + "tools/ci-matrix", ] [workspace.package] diff --git a/README.md b/README.md index fdd7119..ab033cf 100644 --- a/README.md +++ b/README.md @@ -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 | | 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: ```sh cargo test -p libremetaverse-imaging --features jpeg2000 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, offline-build, cache, and redistribution details are in the [`OpenJPEG adapter guide`](crates/libremetaverse-openjpeg/README.md) and [`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 implemented yet. There is deliberately no enablement command for it today; the existing `jpeg2000` feature always selects the OpenJPEG backend. diff --git a/ci/release-matrix.json b/ci/release-matrix.json new file mode 100644 index 0000000..31a9ce5 --- /dev/null +++ b/ci/release-matrix.json @@ -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"] + } + ] + } + ] +} diff --git a/crates/libremetaverse/Cargo.toml b/crates/libremetaverse/Cargo.toml index 0ceac71..455ce5c 100644 --- a/crates/libremetaverse/Cargo.toml +++ b/crates/libremetaverse/Cargo.toml @@ -8,9 +8,10 @@ repository.workspace = true description = "Rust rewrite shell for the LibreMetaverse client library" [features] -default = ["dds-bc67", "jpeg2000"] +default = ["dds-bc67"] dds-bc67 = ["dep:bcdec_rs"] jpeg2000 = ["libremetaverse-imaging/jpeg2000"] +vorbis = ["dep:vorbis_rs"] [dependencies] base64 = "0.22.1" @@ -28,7 +29,7 @@ roxmltree = "0.21.1" serde_json = "1.0.143" tar = "0.4.46" 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] tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread", "sync", "test-util", "time"] } diff --git a/crates/libremetaverse/src/asset_models.rs b/crates/libremetaverse/src/asset_models.rs index faea994..2efa09b 100644 --- a/crates/libremetaverse/src/asset_models.rs +++ b/crates/libremetaverse/src/asset_models.rs @@ -155,20 +155,14 @@ raw_asset!(AssetScriptBinary, AssetType::LSLBytecode); raw_asset!(AssetSound, AssetType::Sound); impl AssetSound { + #[cfg(feature = "vorbis")] pub fn pcm_to_ogg( pcm_data: Vec, sample_rate: i32, channels: i32, bits_per_sample: Option, ) -> 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); - } + validate_pcm(&pcm_data, sample_rate, channels, bits_per_sample)?; let bits = bits_per_sample.unwrap_or(16); let bytes_per_sample = usize::try_from(bits / 8).map_err(|_| Error::Argument)?; let channels = usize::try_from(channels).map_err(|_| Error::Argument)?; @@ -215,6 +209,43 @@ impl AssetSound { validate_bytes(&ogg_data)?; Ok(ogg_data) } + + #[cfg(not(feature = "vorbis"))] + pub fn pcm_to_ogg( + pcm_data: Vec, + sample_rate: i32, + channels: i32, + bits_per_sample: Option, + ) -> Result, 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, +) -> 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)] diff --git a/crates/libremetaverse/src/asset_pipeline_semantics.rs b/crates/libremetaverse/src/asset_pipeline_semantics.rs index 316f045..76e7a18 100644 --- a/crates/libremetaverse/src/asset_pipeline_semantics.rs +++ b/crates/libremetaverse/src/asset_pipeline_semantics.rs @@ -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::{ AssetCache, AssetCacheComputeAssetCacheFilenameDelegate, Error, GridClient, ImageCodec, }; +#[cfg(feature = "jpeg2000")] use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; use libremetaverse_types::{AssetType, UUID}; use std::fs; @@ -33,10 +36,28 @@ fn asset_models_retain_type_and_reject_malformed_meshes() { } #[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(); 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(); image.red.fill(255); let texture = AssetTexture::new_with_managed_image(image).unwrap(); diff --git a/crates/libremetaverse/src/object_manager.rs b/crates/libremetaverse/src/object_manager.rs index c19076c..070b18e 100644 --- a/crates/libremetaverse/src/object_manager.rs +++ b/crates/libremetaverse/src/object_manager.rs @@ -2913,12 +2913,10 @@ mod tests { block.name = b"Native object".to_vec(); block.description = b"decoded".to_vec(); packet.object_data = vec![block]; + let wire = transport_wire(&packet.to_bytes_with_method().expect("wire packet")); manager .inner - .handle_object_properties( - &packet.to_bytes_with_method().expect("wire packet"), - simulator.clone(), - ) + .handle_object_properties(&wire, simulator.clone()) .expect("properties reply"); assert!(observed.load(Ordering::Acquire)); assert_eq!( diff --git a/crates/libremetaverse/tests/packet_catalog.rs b/crates/libremetaverse/tests/packet_catalog.rs index 6aa603e..770c3ff 100644 --- a/crates/libremetaverse/tests/packet_catalog.rs +++ b/crates/libremetaverse/tests/packet_catalog.rs @@ -104,9 +104,9 @@ fn generated_constructors_initialize_blocks_headers_and_sizing_metadata() { let ack = PacketAckPacket::new_with_constructor().unwrap(); assert!(ack.packets.is_empty()); - // The source generator includes one variable-block metadata byte in the - // base and one in the block contribution; preserve that exact Length API. - assert_eq!(ack.length(), 12); + // The low-frequency header contributes ten bytes and the empty variable + // block contributes its single count byte. + assert_eq!(ack.length(), 11); assert!(!ack.uses_buffer_pooling()); let circuit = UseCircuitCodePacket::new_with_constructor().unwrap(); diff --git a/docs/assets.md b/docs/assets.md index 6e6770c..62dcb0f 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -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 input ceiling and reject malformed structured formats with typed errors. Mesh and settings parsing use StructuredData. Texture encode/decode uses the imaging -crate's bounded JPEG 2000 adapter, while PCM conversion streams bounded planar -blocks through the cross-platform Ogg Vorbis encoder; neither API exposes codec -implementation types. +crate's bounded `jpeg2000` adapter, while the opt-in `vorbis` feature streams +PCM conversion through bounded planar Ogg Vorbis blocks; neither API exposes +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`. ViewerAsset, GetMesh, GetTexture, and server-bake requests check the cache before diff --git a/docs/release-ci-matrix.md b/docs/release-ci-matrix.md new file mode 100644 index 0000000..8a88ea5 --- /dev/null +++ b/docs/release-ci-matrix.md @@ -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/` 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. diff --git a/tools/ci-matrix/Cargo.toml b/tools/ci-matrix/Cargo.toml new file mode 100644 index 0000000..f3e3605 --- /dev/null +++ b/tools/ci-matrix/Cargo.toml @@ -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 diff --git a/tools/ci-matrix/src/lib.rs b/tools/ci-matrix/src/lib.rs new file mode 100644 index 0000000..642ae8e --- /dev/null +++ b/tools/ci-matrix/src/lib.rs @@ -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) -> 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 for MatrixError { + fn from(error: std::io::Error) -> Self { + Self::new(error.to_string()) + } +} + +impl From for MatrixError { + fn from(error: serde_json::Error) -> Self { + Self::new(error.to_string()) + } +} + +pub type Result = std::result::Result; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ReleaseMatrix { + pub schema: u32, + pub msrv: String, + pub current: String, + pub native_prerequisites: Vec, + pub manual_gates: Vec, + pub profiles: Vec, +} + +#[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, + pub prerequisites: Vec, + pub commands: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CargoInvocation { + pub label: String, + pub args: Vec, +} + +#[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 { + 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 { + 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, kind: &str) -> Result> { + 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::>(); + 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::>>()?; + 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) -> Result { + command_line(program, ["--version"], Path::new(".")) +} + +fn command_line( + program: impl AsRef, + args: impl IntoIterator>, + directory: &Path, +) -> Result { + 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()); + } +} diff --git a/tools/ci-matrix/src/main.rs b/tools/ci-matrix/src/main.rs new file mode 100644 index 0000000..e79cfa6 --- /dev/null +++ b/tools/ci-matrix/src/main.rs @@ -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> { + let current = std::env::current_dir()?; + let root = workspace_root(¤t).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) + } +}