diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 22:16:50 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 22:16:50 +0300 |
| commit | 1d0244c3e45b400b75af1895257b64ca056cfd8a (patch) | |
| tree | d348b19372710427b5e8c3e3b7cde9202a1bf867 | |
| parent | 5d9e1cbe3877036bea74f69ec037bb7d5d6e9ad5 (diff) | |
| download | fparkan-1d0244c3e45b400b75af1895257b64ca056cfd8a.tar.xz fparkan-1d0244c3e45b400b75af1895257b64ca056cfd8a.zip | |
ci: enforce reproducible Rust toolchain
| -rw-r--r-- | .github/workflows/ci.yml | 38 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 2 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 2 | ||||
| -rw-r--r-- | xtask/src/main.rs | 133 |
4 files changed, 175 insertions, 0 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6102088..eec6d61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,44 @@ on: branches: [main] jobs: + msrv-backend-neutral: + name: MSRV backend-neutral crates + runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.87.0 + - name: Test backend-neutral crates + run: > + cargo test + -p fparkan-animation + -p fparkan-binary + -p fparkan-corpus + -p fparkan-diagnostics + -p fparkan-fx + -p fparkan-inspection + -p fparkan-material + -p fparkan-mission-format + -p fparkan-msh + -p fparkan-nres + -p fparkan-path + -p fparkan-platform + -p fparkan-prototype + -p fparkan-render + -p fparkan-resource + -p fparkan-rsli + -p fparkan-runtime + -p fparkan-terrain + -p fparkan-terrain-format + -p fparkan-texm + -p fparkan-vfs + -p fparkan-world + --all-targets + --locked + stage0-matrix: name: Stage 0-2 CI (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index ec8e7d7..458180c 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -12,6 +12,8 @@ S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsaf S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/ S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny or built-in supply-chain fallback, and strict acceptance audit +S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift +S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md index 84aa8a5..a710d09 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -12,6 +12,8 @@ `S0-ARCH-005` `S0-ARCH-006` `S0-ARCH-007` +`S0-ARCH-008` +`S0-ARCH-009` `S0-DIAG-001` `S0-DIAG-002` `S0-CORPUS-001` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 07118db..f932761 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -39,6 +39,8 @@ const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv"; const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json"; const APPROVED_REGISTRY_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; const SUPPLY_CHAIN_BANNED_PACKAGES: &[&str] = &["native-tls", "openssl", "openssl-sys"]; +const PINNED_RUST_TOOLCHAIN: &str = "1.87.0"; +const WORKSPACE_MSRV: &str = "1.87"; fn main() { let args = std::env::args().skip(1).collect::<Vec<_>>(); @@ -447,6 +449,7 @@ fn run_package(options: &PackageOptions) -> Result<(), String> { fn run_policy(root: &Path) -> Result<(), String> { let mut failures = Vec::new(); + validate_toolchain_policy(root, &mut failures)?; scan_policy_dir(root, &mut failures)?; validate_cargo_metadata(root, &mut failures)?; validate_cargo_metadata_dependency_closures(root, &mut failures)?; @@ -544,6 +547,86 @@ fn dependency_closure_names( names } +fn validate_toolchain_policy(root: &Path, failures: &mut Vec<String>) -> Result<(), String> { + let toolchain_path = root.join("rust-toolchain.toml"); + let toolchain_text = fs::read_to_string(&toolchain_path) + .map_err(|err| format!("{}: {err}", toolchain_path.display()))?; + let toolchain = toml::from_str::<RustToolchainToml>(&toolchain_text) + .map_err(|err| format!("{}: invalid TOML: {err}", toolchain_path.display()))?; + if toolchain.toolchain.channel != PINNED_RUST_TOOLCHAIN { + failures.push(format!( + "{}: toolchain channel must be exact {PINNED_RUST_TOOLCHAIN}", + toolchain_path.display() + )); + } + if !is_exact_rust_patch_version(&toolchain.toolchain.channel) { + failures.push(format!( + "{}: toolchain channel must include major.minor.patch, not a moving channel", + toolchain_path.display() + )); + } + + let manifest_path = root.join("Cargo.toml"); + let manifest_text = fs::read_to_string(&manifest_path) + .map_err(|err| format!("{}: {err}", manifest_path.display()))?; + let manifest = toml::from_str::<WorkspaceManifestToml>(&manifest_text) + .map_err(|err| format!("{}: invalid TOML: {err}", manifest_path.display()))?; + if manifest.workspace.package.rust_version != WORKSPACE_MSRV { + failures.push(format!( + "{}: workspace.package.rust-version must be {WORKSPACE_MSRV}", + manifest_path.display() + )); + } + if !PINNED_RUST_TOOLCHAIN.starts_with(&format!("{}.", manifest.workspace.package.rust_version)) + { + failures.push(format!( + "{}: workspace.package.rust-version must match pinned toolchain major.minor", + manifest_path.display() + )); + } + Ok(()) +} + +fn is_exact_rust_patch_version(value: &str) -> bool { + let parts = value.split('.').collect::<Vec<_>>(); + parts.len() == 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RustToolchainToml { + toolchain: RustToolchainTable, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RustToolchainTable { + channel: String, + #[allow(dead_code)] + components: Option<Vec<String>>, + #[allow(dead_code)] + targets: Option<Vec<String>>, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceManifestToml { + workspace: WorkspaceTable, +} + +#[derive(Debug, Deserialize)] +struct WorkspaceTable { + package: WorkspacePackageTable, +} + +#[derive(Debug, Deserialize)] +struct WorkspacePackageTable { + #[serde(rename = "rust-version")] + rust_version: String, +} + fn validate_lockfile(root: &Path, failures: &mut Vec<String>) { let lockfile = root.join("Cargo.lock"); if !lockfile.is_file() { @@ -2021,6 +2104,56 @@ fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } } #[test] + fn toolchain_policy_rejects_moving_toolchain() -> Result<(), String> { + let root = temp_dir("toolchain-moving"); + fs::create_dir_all(&root).map_err(|err| err.to_string())?; + fs::write( + root.join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"stable\"\n", + ) + .map_err(|err| err.to_string())?; + fs::write( + root.join("Cargo.toml"), + "[workspace]\n[workspace.package]\nrust-version = \"1.87\"\n", + ) + .map_err(|err| err.to_string())?; + + let mut failures = Vec::new(); + validate_toolchain_policy(&root, &mut failures)?; + + assert_eq!(failures.len(), 2); + assert!(failures[0].contains("must be exact")); + assert!(failures[1].contains("major.minor.patch")); + fs::remove_dir_all(root).map_err(|err| err.to_string())?; + Ok(()) + } + + #[test] + fn toolchain_policy_rejects_msrv_mismatch() -> Result<(), String> { + let root = temp_dir("toolchain-msrv"); + fs::create_dir_all(&root).map_err(|err| err.to_string())?; + fs::write( + root.join("rust-toolchain.toml"), + format!("[toolchain]\nchannel = \"{PINNED_RUST_TOOLCHAIN}\"\n"), + ) + .map_err(|err| err.to_string())?; + fs::write( + root.join("Cargo.toml"), + "[workspace]\n[workspace.package]\nrust-version = \"1.86\"\n", + ) + .map_err(|err| err.to_string())?; + + let mut failures = Vec::new(); + validate_toolchain_policy(&root, &mut failures)?; + + assert_eq!(failures.len(), 2); + assert!(failures[0].contains("rust-version must be")); + assert!(failures[1].contains("must match pinned toolchain")); + fs::remove_dir_all(root).map_err(|err| err.to_string())?; + Ok(()) + } + + #[test] fn lockfile_supply_chain_rejects_unapproved_sources() -> Result<(), String> { let root = temp_dir("lockfile-source"); fs::create_dir_all(&root).map_err(|err| err.to_string())?; |
