aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 22:16:50 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 22:16:50 +0300
commit1d0244c3e45b400b75af1895257b64ca056cfd8a (patch)
treed348b19372710427b5e8c3e3b7cde9202a1bf867
parent5d9e1cbe3877036bea74f69ec037bb7d5d6e9ad5 (diff)
downloadfparkan-1d0244c3e45b400b75af1895257b64ca056cfd8a.tar.xz
fparkan-1d0244c3e45b400b75af1895257b64ca056cfd8a.zip
ci: enforce reproducible Rust toolchain
-rw-r--r--.github/workflows/ci.yml38
-rw-r--r--fixtures/acceptance/coverage.tsv2
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md2
-rw-r--r--xtask/src/main.rs133
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())?;