aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-25 02:45:23 +0300
committerValentin Popov <valentin@popov.link>2026-06-25 10:45:32 +0300
commit5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018 (patch)
treea4d02bb919998283e10ba018d2f757b227445591
parent27af3806b342cfe82fdd7050a1577f6e45aa8c26 (diff)
downloadfparkan-5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018.tar.xz
fparkan-5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018.zip
ci: tighten stage 0 acceptance gates
-rw-r--r--.github/workflows/ci.yml24
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs56
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs19
-rw-r--r--deny.toml35
-rw-r--r--fixtures/acceptance/stage_packages.toml47
-rw-r--r--xtask/src/main.rs380
6 files changed, 424 insertions, 137 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f0c90e0..76ceca6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,19 +2,21 @@ name: fparkan-ci
on:
push:
- branches: [main]
+ branches: [devel, main]
pull_request:
- branches: [main]
+ branches: [devel, main]
+ workflow_dispatch:
jobs:
msrv-backend-neutral:
name: MSRV backend-neutral crates
runs-on: ubuntu-latest
+ timeout-minutes: 20
env:
CARGO_TERM_COLOR: always
steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@master
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
toolchain: 1.87.0
- name: Test backend-neutral crates
@@ -48,6 +50,7 @@ jobs:
stage0-matrix:
name: Stage 0-2 CI (${{ matrix.os }})
runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -61,12 +64,13 @@ jobs:
env:
CARGO_TERM_COLOR: always
steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@master
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
- toolchain-file: rust-toolchain.toml
+ toolchain: 1.87.0
+ components: clippy,rustfmt
- name: Install cargo-deny
- run: cargo install cargo-deny --locked
+ run: cargo install cargo-deny --version 0.19.9 --locked
- name: Run canonical CI gate
run: cargo xtask ci
- name: Record native Vulkan smoke status
@@ -81,10 +85,10 @@ jobs:
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
- name: Upload acceptance evidence
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: stage-0-2-acceptance-${{ matrix.os }}
path: |
target/fparkan/acceptance/stage-0-2-audit.json
target/fparkan/native-smoke/*.json
- if-no-files-found: ignore
+ if-no-files-found: error
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs
index ee89e46..4490d81 100644
--- a/adapters/fparkan-render-vulkan/src/lib.rs
+++ b/adapters/fparkan-render-vulkan/src/lib.rs
@@ -345,14 +345,20 @@ impl VulkanLogicalDeviceProbe {
#[must_use]
pub fn graphics_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device.
- unsafe { self.device.get_device_queue(self.report.graphics_queue_family, 0) }
+ unsafe {
+ self.device
+ .get_device_queue(self.report.graphics_queue_family, 0)
+ }
}
/// Returns the presentation queue selected by the Stage 0 policy.
#[must_use]
pub fn present_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device.
- unsafe { self.device.get_device_queue(self.report.present_queue_family, 0) }
+ unsafe {
+ self.device
+ .get_device_queue(self.report.present_queue_family, 0)
+ }
}
/// Returns a shared reference to the live logical device.
@@ -440,8 +446,12 @@ pub enum VulkanSmokeRunError {
impl std::fmt::Display for VulkanSmokeRunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
- Self::AcquireImage { result } => write!(f, "failed to acquire swapchain image: {result}"),
- Self::PresentImage { result } => write!(f, "failed to present swapchain image: {result}"),
+ Self::AcquireImage { result } => {
+ write!(f, "failed to acquire swapchain image: {result}")
+ }
+ Self::PresentImage { result } => {
+ write!(f, "failed to present swapchain image: {result}")
+ }
Self::RecreateSwapchain { result } => {
write!(f, "failed to recreate swapchain: {result}")
}
@@ -464,9 +474,11 @@ pub fn run_vulkan_smoke_pass(
let timeout_ns = u64::MAX;
let image_available = vk::SemaphoreCreateInfo::default();
- let image_ready = unsafe { device.device().create_semaphore(&image_available, None) }
- .map_err(|error| VulkanSmokeRunError::RecreateSwapchain {
- result: format!("{error:?}"),
+ let image_ready =
+ unsafe { device.device().create_semaphore(&image_available, None) }.map_err(|error| {
+ VulkanSmokeRunError::RecreateSwapchain {
+ result: format!("{error:?}"),
+ }
})?;
let recreate_interval = if recreate_count == 0 {
@@ -479,18 +491,27 @@ pub fn run_vulkan_smoke_pass(
let mut created = 0_u32;
for frame in 0..frames {
- if recreate_interval > 0 && frame > 0 && frame % recreate_interval == 0 && created < recreate_count {
- swapchain = create_vulkan_swapchain_probe(instance, surface, device)
- .map_err(|error| VulkanSmokeRunError::RecreateSwapchain {
- result: error.to_string(),
+ if recreate_interval > 0
+ && frame > 0
+ && frame % recreate_interval == 0
+ && created < recreate_count
+ {
+ swapchain =
+ create_vulkan_swapchain_probe(instance, surface, device).map_err(|error| {
+ VulkanSmokeRunError::RecreateSwapchain {
+ result: error.to_string(),
+ }
})?;
created = created.saturating_add(1);
}
let image_index = unsafe {
- swapchain
- .loader()
- .acquire_next_image(swapchain.swapchain(), timeout_ns, image_ready, vk::Fence::null())
+ swapchain.loader().acquire_next_image(
+ swapchain.swapchain(),
+ timeout_ns,
+ image_ready,
+ vk::Fence::null(),
+ )
}
.map(|(index, _)| index)
.map_err(|error| VulkanSmokeRunError::AcquireImage {
@@ -513,10 +534,11 @@ pub fn run_vulkan_smoke_pass(
result: format!("{error:?}"),
})?;
- unsafe { device.device().queue_wait_idle(render_queue) }
- .map_err(|error| VulkanSmokeRunError::PresentImage {
+ unsafe { device.device().queue_wait_idle(render_queue) }.map_err(|error| {
+ VulkanSmokeRunError::PresentImage {
result: format!("{error:?}"),
- })?;
+ }
+ })?;
swaps = swaps.saturating_add(1);
}
diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs
index 59499bd..4e60ec8 100644
--- a/apps/fparkan-vulkan-smoke/src/main.rs
+++ b/apps/fparkan-vulkan-smoke/src/main.rs
@@ -16,8 +16,8 @@ use fparkan_platform_winit::{probe_smoke_window, WinitWindowPlan};
use fparkan_render_vulkan::{
create_vulkan_instance_probe, create_vulkan_logical_device_probe, create_vulkan_surface_probe,
create_vulkan_swapchain_probe, probe_vulkan_loader, run_vulkan_smoke_pass,
- triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig,
- VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSwapchainProbe,
+ triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, VulkanInstanceProbe,
+ VulkanLogicalDeviceProbe, VulkanSwapchainProbe,
};
use std::path::PathBuf;
use std::process::Command;
@@ -67,9 +67,13 @@ fn run(args: &[String]) -> Result<String, String> {
return Err("passed native smoke report requires frames to be advanced".to_string());
}
if smoke_run.validation_error_count
- != options.validation_error_count.unwrap_or(smoke_run.validation_error_count)
+ != options
+ .validation_error_count
+ .unwrap_or(smoke_run.validation_error_count)
{
- return Err("passed native smoke report requires validation errors to be zero".to_string());
+ return Err(
+ "passed native smoke report requires validation errors to be zero".to_string(),
+ );
}
}
let report = render_smoke_report_json(&options, &bootstrap)?;
@@ -443,12 +447,13 @@ impl VulkanBootstrapProbe {
options: &SmokeOptions,
instance: &VulkanInstanceProbe,
window_handles: Option<NativeWindowHandles>,
- ) -> Option<fparkan_render_vulkan::VulkanSurfaceProbe>
- {
+ ) -> Option<fparkan_render_vulkan::VulkanSurfaceProbe> {
if options.probes.vulkan.includes_surface()
&& self.instance_status == VulkanInstanceStatus::Created
{
- match create_vulkan_surface_probe(instance, window_handles).map_err(|err| err.to_string()) {
+ match create_vulkan_surface_probe(instance, window_handles)
+ .map_err(|err| err.to_string())
+ {
Ok(surface) => {
self.surface_status = VulkanSurfaceStatus::Created;
return Some(surface);
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 0000000..7717806
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,35 @@
+[graph]
+all-features = true
+
+[advisories]
+yanked = "deny"
+
+[bans]
+multiple-versions = "allow"
+wildcards = "deny"
+deny = [
+ { name = "native-tls" },
+ { name = "openssl" },
+ { name = "openssl-sys" },
+]
+
+[licenses]
+unlicensed = "deny"
+copyleft = "allow"
+allow = [
+ "Apache-2.0",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "CC0-1.0",
+ "GPL-2.0-only",
+ "ISC",
+ "MIT",
+ "MPL-2.0",
+ "Unicode-3.0",
+ "Zlib",
+]
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
diff --git a/fixtures/acceptance/stage_packages.toml b/fixtures/acceptance/stage_packages.toml
new file mode 100644
index 0000000..9a0faaa
--- /dev/null
+++ b/fixtures/acceptance/stage_packages.toml
@@ -0,0 +1,47 @@
+schema = 1
+
+[stages]
+"0" = [
+ "fparkan-binary",
+ "fparkan-corpus",
+ "fparkan-diagnostics",
+ "fparkan-platform",
+ "fparkan-platform-winit",
+ "fparkan-render",
+ "fparkan-render-vulkan",
+ "fparkan-test-support",
+ "fparkan-vulkan-smoke",
+ "xtask",
+]
+"1" = [
+ "fparkan-cli",
+ "fparkan-inspection",
+ "fparkan-nres",
+ "fparkan-path",
+ "fparkan-resource",
+ "fparkan-rsli",
+ "fparkan-vfs",
+]
+"2" = [
+ "fparkan-prototype",
+]
+"3" = [
+ "fparkan-assets",
+ "fparkan-material",
+ "fparkan-msh",
+ "fparkan-texm",
+ "fparkan-viewer",
+]
+"4" = [
+ "fparkan-animation",
+ "fparkan-fx",
+]
+"5" = [
+ "fparkan-game",
+ "fparkan-headless",
+ "fparkan-mission-format",
+ "fparkan-runtime",
+ "fparkan-terrain",
+ "fparkan-terrain-format",
+ "fparkan-world",
+]
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index ed40de8..6a9bbb7 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -37,11 +37,24 @@ const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT";
const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_2_roadmap.md";
const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv";
const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json";
+const STAGE_PACKAGE_MANIFEST: &str = "fixtures/acceptance/stage_packages.toml";
const REQUIRED_NATIVE_SMOKE_PLATFORMS: &[&str] = &["linux", "macos", "windows"];
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";
+const ALLOW_SUPPLY_CHAIN_FALLBACK_ENV: &str = "FPARKAN_ALLOW_SUPPLY_CHAIN_FALLBACK";
+
+fn workspace_root_path() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
+ .to_path_buf()
+}
+
+fn workspace_relative_path(path: &str) -> PathBuf {
+ workspace_root_path().join(path)
+}
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
@@ -180,13 +193,27 @@ fn run_cargo_fmt_check() -> Result<(), String> {
fn run_cargo_deny() -> Result<(), String> {
let cargo_deny = std::env::var_os("CARGO_DENY").unwrap_or_else(|| "cargo-deny".into());
- let available = Command::new(&cargo_deny).arg("--version").status();
- match available {
- Ok(status) if status.success() => {}
- Ok(_) | Err(_) => {
- eprintln!("cargo-deny is unavailable; running built-in supply-chain policy fallback");
- return run_builtin_supply_chain_policy(Path::new("."));
- }
+ let version_output = Command::new(&cargo_deny)
+ .arg("--version")
+ .output()
+ .map_err(|err| {
+ format!(
+ "cargo-deny is required; install cargo-deny {PINNED_CARGO_DENY_VERSION} or set {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV}=1 for the built-in fallback: {err}"
+ )
+ })?;
+ if !version_output.status.success() {
+ return handle_cargo_deny_fallback(format!(
+ "cargo-deny --version exited with {}",
+ version_output.status
+ ));
+ }
+ let version_text = String::from_utf8(version_output.stdout)
+ .map_err(|err| format!("cargo-deny --version produced invalid UTF-8: {err}"))?;
+ if !version_text.contains(PINNED_CARGO_DENY_VERSION) {
+ return handle_cargo_deny_fallback(format!(
+ "cargo-deny version mismatch: expected {PINNED_CARGO_DENY_VERSION}, found {}",
+ version_text.trim()
+ ));
}
let status = Command::new(cargo_deny)
@@ -208,6 +235,21 @@ fn run_cargo_deny() -> Result<(), String> {
}
}
+const PINNED_CARGO_DENY_VERSION: &str = "0.19.9";
+
+fn handle_cargo_deny_fallback(reason: String) -> Result<(), String> {
+ if std::env::var_os(ALLOW_SUPPLY_CHAIN_FALLBACK_ENV).is_some() {
+ eprintln!(
+ "{reason}; running built-in supply-chain policy fallback because {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV} is set"
+ );
+ run_builtin_supply_chain_policy(Path::new("."))
+ } else {
+ Err(format!(
+ "{reason}; install cargo-deny {PINNED_CARGO_DENY_VERSION} or explicitly opt into the fallback with {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV}=1"
+ ))
+ }
+}
+
fn run_builtin_supply_chain_policy(root: &Path) -> Result<(), String> {
let mut failures = Vec::new();
validate_workspace_license(root, &mut failures)?;
@@ -473,12 +515,7 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
if !manifest.exists() {
return Ok(());
}
- let metadata = MetadataCommand::new()
- .manifest_path(&manifest)
- .no_deps()
- .other_options(["--offline".to_string(), "--locked".to_string()])
- .exec()
- .map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error))?;
+ let metadata = workspace_metadata(root)?;
if metadata.workspace_members.is_empty() {
failures.push(format!(
"{}: cargo metadata produced no workspace members",
@@ -486,6 +523,18 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
));
return Ok(());
}
+ let stage_manifest_path = root.join(STAGE_PACKAGE_MANIFEST);
+ let stage_manifest = load_stage_package_manifest(&stage_manifest_path)?;
+ let workspace_packages = metadata
+ .workspace_packages()
+ .iter()
+ .map(|package| package.name.to_string())
+ .collect::<BTreeSet<_>>();
+ if let Err(err) =
+ validate_stage_package_entries(&stage_manifest, &workspace_packages, &stage_manifest_path)
+ {
+ failures.push(err);
+ }
Ok(())
}
@@ -1185,40 +1234,6 @@ enum Stage {
Number(u8),
}
-const ALL_WORKSPACE_PACKAGES: &[&str] = &[
- "fparkan-animation",
- "fparkan-assets",
- "fparkan-binary",
- "fparkan-corpus",
- "fparkan-diagnostics",
- "fparkan-fx",
- "fparkan-material",
- "fparkan-mission-format",
- "fparkan-msh",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-platform",
- "fparkan-prototype",
- "fparkan-render",
- "fparkan-resource",
- "fparkan-rsli",
- "fparkan-runtime",
- "fparkan-terrain",
- "fparkan-terrain-format",
- "fparkan-test-support",
- "fparkan-texm",
- "fparkan-vfs",
- "fparkan-world",
- "fparkan-platform-winit",
- "fparkan-render-vulkan",
- "fparkan-cli",
- "fparkan-game",
- "fparkan-headless",
- "fparkan-vulkan-smoke",
- "fparkan-viewer",
- "xtask",
-];
-
impl Stage {
fn parse(value: &str) -> Result<Self, String> {
if value == "all" {
@@ -1390,9 +1405,10 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
}
fn parse_audit_options(args: &[String]) -> Result<AuditOptions, String> {
- let mut roadmap = PathBuf::from("FPARKAN_ARCHITECTURE_ROADMAP_STAGES_0_5.md");
- let mut coverage = PathBuf::from("fixtures/acceptance/coverage.tsv");
- let mut out = PathBuf::from("target")
+ let mut roadmap = workspace_relative_path(CI_ACCEPTANCE_ROADMAP);
+ let mut coverage = workspace_relative_path(CI_ACCEPTANCE_COVERAGE);
+ let mut out = workspace_root_path()
+ .join("target")
.join("fparkan")
.join("reports")
.join("acceptance")
@@ -1478,25 +1494,55 @@ fn read_native_smoke_reports(dir: &Path) -> Result<BTreeMap<String, serde_json::
.map_err(|err| format!("{}: {err}", path.display()))?;
let platform = json_string_field(&json, "platform")
.map_err(|err| format!("{}: {err}", path.display()))?;
- reports.insert(platform.to_string(), json);
+ let platform = platform.to_string();
+ if reports.insert(platform.clone(), json).is_some() {
+ return Err(format!(
+ "{}: duplicate native smoke report for platform {platform}",
+ path.display()
+ ));
+ }
}
Ok(reports)
}
fn audit_native_smoke_reports(reports: &BTreeMap<String, serde_json::Value>) -> Vec<String> {
let mut failures = Vec::new();
+ let mut commit_shas = BTreeSet::new();
+ let mut rust_toolchains = BTreeSet::new();
for platform in REQUIRED_NATIVE_SMOKE_PLATFORMS {
let Some(report) = reports.get(*platform) else {
failures.push(format!("{platform}: missing native smoke report"));
continue;
};
validate_native_smoke_report(platform, report, &mut failures);
+ if let Ok(commit_sha) = json_string_field(report, "commit_sha") {
+ if commit_sha == "unknown" {
+ failures.push(format!("{platform}: commit_sha must not be \"unknown\""));
+ } else {
+ commit_shas.insert(commit_sha.to_string());
+ }
+ }
+ if let Ok(toolchain) = json_string_field(report, "rust_toolchain") {
+ rust_toolchains.insert(toolchain.to_string());
+ }
}
for platform in reports.keys() {
if !REQUIRED_NATIVE_SMOKE_PLATFORMS.contains(&platform.as_str()) {
failures.push(format!("{platform}: unexpected native smoke platform"));
}
}
+ if commit_shas.len() > 1 {
+ failures.push(format!(
+ "native smoke reports disagree on commit_sha: {}",
+ commit_shas.into_iter().collect::<Vec<_>>().join(", ")
+ ));
+ }
+ if rust_toolchains.len() > 1 {
+ failures.push(format!(
+ "native smoke reports disagree on rust_toolchain: {}",
+ rust_toolchains.into_iter().collect::<Vec<_>>().join(", ")
+ ));
+ }
failures
}
@@ -1561,7 +1607,14 @@ fn validate_native_smoke_report(
expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures);
expect_u64_field(platform, report, "validation_error_count", 0, failures);
expect_nonempty_string(platform, report, "commit_sha", failures);
- expect_nonempty_string(platform, report, "rust_toolchain", failures);
+ expect_string_field(
+ platform,
+ report,
+ "rust_toolchain",
+ &measured_rust_toolchain_version(),
+ failures,
+ );
+ expect_string_field(platform, report, "platform", platform, failures);
expect_nonempty_string(platform, report, "target_triple", failures);
expect_nonempty_string(platform, report, "shader_manifest_hash", failures);
expect_nonempty_string(platform, report, "vulkan_device_name", failures);
@@ -1741,13 +1794,20 @@ impl AcceptanceAudit {
self.partial
.iter()
.chain(&self.blocked)
+ .chain(&self.omitted)
.chain(&self.missing)
.cloned()
.collect()
}
fn strict_failures(&self) -> Vec<String> {
- self.partial.iter().chain(&self.missing).cloned().collect()
+ self.partial
+ .iter()
+ .chain(&self.blocked)
+ .chain(&self.omitted)
+ .chain(&self.missing)
+ .cloned()
+ .collect()
}
}
@@ -1855,7 +1915,7 @@ fn build_acceptance_audit(
AcceptanceAudit {
commit_sha: current_git_commit_sha(),
- rust_toolchain: PINNED_RUST_TOOLCHAIN.to_string(),
+ rust_toolchain: measured_rust_toolchain_version(),
msrv: WORKSPACE_MSRV.to_string(),
required_total: required.len(),
covered,
@@ -1930,6 +1990,23 @@ fn current_git_commit_sha() -> String {
.unwrap_or_else(|| "unknown".to_string())
}
+fn measured_rust_toolchain_version() -> String {
+ Command::new("rustc")
+ .args(["-Vv"])
+ .output()
+ .ok()
+ .filter(|output| output.status.success())
+ .and_then(|output| String::from_utf8(output.stdout).ok())
+ .and_then(|stdout| {
+ stdout.lines().find_map(|line| {
+ line.strip_prefix("release:")
+ .map(str::trim)
+ .map(ToString::to_string)
+ })
+ })
+ .unwrap_or_else(|| PINNED_RUST_TOOLCHAIN.to_string())
+}
+
fn render_string_usize_map(values: &BTreeMap<String, usize>) -> String {
let pairs = values
.iter()
@@ -1994,6 +2071,7 @@ fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> {
fn render_acceptance_report(options: &AcceptanceOptions) -> String {
let packages = stage_report_packages(options.stage)
+ .unwrap_or_default()
.into_iter()
.map(|package| format!(" \"{package}\""))
.collect::<Vec<_>>()
@@ -2023,10 +2101,12 @@ fn render_acceptance_report(options: &AcceptanceOptions) -> String {
)
}
-fn stage_report_packages(stage: Stage) -> Vec<&'static str> {
+fn stage_report_packages(stage: Stage) -> Result<Vec<String>, String> {
+ let workspace_root = workspace_root_path();
match stage {
- Stage::All => ALL_WORKSPACE_PACKAGES.to_vec(),
- Stage::Number(number) => stage_packages(number).unwrap_or(&[]).to_vec(),
+ Stage::All => workspace_package_names(&workspace_root)
+ .map(|packages| packages.into_iter().collect::<Vec<_>>()),
+ Stage::Number(number) => stage_packages(number),
}
}
@@ -2052,12 +2132,19 @@ fn run_stage_tests(
}
Stage::Number(number) => {
for package in stage_packages(number)? {
- let mut args = vec!["test", "-p", package, "--locked", "--offline"];
- args.extend(suffix.iter().copied());
+ let mut args = vec![
+ "test".to_string(),
+ "-p".to_string(),
+ package,
+ "--locked".to_string(),
+ "--offline".to_string(),
+ ];
+ args.extend(suffix.iter().map(|value| (*value).to_string()));
if let Some(envs) = envs {
- cargo_with_env(&args, &envs)?;
+ let borrowed = args.iter().map(String::as_str).collect::<Vec<_>>();
+ cargo_with_env(&borrowed, &envs)?;
} else {
- cargo(&args)?;
+ cargo_owned(&args)?;
}
}
Ok(())
@@ -2065,43 +2152,108 @@ fn run_stage_tests(
}
}
-fn stage_packages(stage: u8) -> Result<&'static [&'static str], String> {
- match stage {
- 0 => Ok(&[
- "fparkan-corpus",
- "fparkan-diagnostics",
- "fparkan-test-support",
- ]),
- 1 => Ok(&[
- "fparkan-binary",
- "fparkan-path",
- "fparkan-nres",
- "fparkan-rsli",
- "fparkan-resource",
- "fparkan-vfs",
- ]),
- 2 => Ok(&["fparkan-prototype"]),
- 3 => Ok(&[
- "fparkan-msh",
- "fparkan-material",
- "fparkan-texm",
- "fparkan-assets",
- "fparkan-render",
- "fparkan-viewer",
- ]),
- 4 => Ok(&["fparkan-animation", "fparkan-fx"]),
- 5 => Ok(&[
- "fparkan-terrain-format",
- "fparkan-terrain",
- "fparkan-mission-format",
- "fparkan-world",
- "fparkan-runtime",
- "fparkan-headless",
- "fparkan-game",
- "fparkan-vulkan-smoke",
- ]),
- _ => Err(format!("stage out of range: {stage}")),
+fn stage_packages(stage: u8) -> Result<Vec<String>, String> {
+ let manifest_path = workspace_relative_path(STAGE_PACKAGE_MANIFEST);
+ let manifest = load_stage_package_manifest(&manifest_path)?;
+ let packages = manifest
+ .stages
+ .get(&stage.to_string())
+ .cloned()
+ .ok_or_else(|| format!("stage out of range: {stage}"))?;
+ validate_stage_package_entries(
+ &manifest,
+ &workspace_package_names(&workspace_root_path())?,
+ &manifest_path,
+ )?;
+ Ok(packages)
+}
+
+fn workspace_package_names(root: &Path) -> Result<BTreeSet<String>, String> {
+ let metadata = workspace_metadata(root)?;
+ Ok(metadata
+ .workspace_packages()
+ .iter()
+ .map(|package| package.name.to_string())
+ .collect())
+}
+
+fn workspace_metadata(root: &Path) -> Result<cargo_metadata::Metadata, String> {
+ let manifest = root.join("Cargo.toml");
+ MetadataCommand::new()
+ .manifest_path(&manifest)
+ .no_deps()
+ .other_options(["--offline".to_string(), "--locked".to_string()])
+ .exec()
+ .map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StagePackageManifest {
+ schema: Option<u8>,
+ stages: BTreeMap<String, Vec<String>>,
+}
+
+fn load_stage_package_manifest(path: &Path) -> Result<StagePackageManifest, String> {
+ let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
+ let manifest = toml::from_str::<StagePackageManifest>(&text)
+ .map_err(|err| format!("failed to parse {}: {err}", path.display()))?;
+ if manifest.schema != Some(1) {
+ return Err(format!(
+ "{}: unsupported stage package manifest schema {:?} (expected 1)",
+ path.display(),
+ manifest.schema
+ ));
}
+ Ok(manifest)
+}
+
+fn validate_stage_package_entries(
+ manifest: &StagePackageManifest,
+ workspace_packages: &BTreeSet<String>,
+ path: &Path,
+) -> Result<(), String> {
+ let required_stages = (0_u8..=5_u8)
+ .map(|stage| stage.to_string())
+ .collect::<BTreeSet<_>>();
+ let declared_stages = manifest.stages.keys().cloned().collect::<BTreeSet<_>>();
+ if declared_stages != required_stages {
+ return Err(format!(
+ "{}: stage package manifest must declare stages 0 through 5 exactly once",
+ path.display()
+ ));
+ }
+
+ let mut assigned = BTreeSet::new();
+ for (stage, packages) in &manifest.stages {
+ for package in packages {
+ if !workspace_packages.contains(package) {
+ return Err(format!(
+ "{}: stage {stage} references unknown package {package}",
+ path.display()
+ ));
+ }
+ if !assigned.insert(package.clone()) {
+ return Err(format!(
+ "{}: package {package} is assigned to multiple stages",
+ path.display()
+ ));
+ }
+ }
+ }
+
+ let missing = workspace_packages
+ .difference(&assigned)
+ .cloned()
+ .collect::<Vec<_>>();
+ if !missing.is_empty() {
+ return Err(format!(
+ "{}: stage package manifest is missing workspace packages: {}",
+ path.display(),
+ missing.join(", ")
+ ));
+ }
+ Ok(())
}
#[cfg(test)]
@@ -2233,6 +2385,10 @@ mod tests {
assert_eq!(audit.missing, ["S0-ARCH-002"]);
assert_eq!(audit.unknown_coverage, ["S9-UNKNOWN-001"]);
assert_eq!(audit.by_stage.get("S0"), Some(&2));
+ assert_eq!(
+ audit.strict_failures(),
+ strings(&["L5-RG40-001", "L3-DEVICE-001", "S0-ARCH-002"])
+ );
}
#[test]
@@ -2273,7 +2429,7 @@ mod tests {
serde_json::json!({
"schema_version": "fparkan-native-smoke-v1",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
- "rust_toolchain": "1.87.0",
+ "rust_toolchain": measured_rust_toolchain_version(),
"target_triple": format!("{platform}-test-target"),
"platform": platform,
"status": "passed",
@@ -2311,7 +2467,7 @@ mod tests {
serde_json::json!({
"schema_version": "fparkan-native-smoke-v1",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
- "rust_toolchain": "1.87.0",
+ "rust_toolchain": measured_rust_toolchain_version(),
"target_triple": "aarch64-apple-darwin",
"platform": "macos",
"status": "blocked",
@@ -2412,14 +2568,32 @@ mod tests {
#[test]
fn maps_stage_packages() {
- assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-assets")));
- assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-viewer")));
- assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-runtime")));
- assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-game")));
+ assert!(stage_packages(0)
+ .is_ok_and(|packages| packages.contains(&"fparkan-platform".to_string())));
+ assert!(stage_packages(0)
+ .is_ok_and(|packages| packages.contains(&"fparkan-vulkan-smoke".to_string())));
+ assert!(stage_packages(1)
+ .is_ok_and(|packages| packages.contains(&"fparkan-inspection".to_string())));
+ assert!(stage_packages(5)
+ .is_ok_and(|packages| packages.contains(&"fparkan-runtime".to_string())));
+ assert!(
+ stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-game".to_string()))
+ );
assert_eq!(stage_packages(9), Err("stage out of range: 9".to_string()));
}
#[test]
+ fn stage_package_manifest_covers_workspace_once() -> Result<(), String> {
+ let manifest_path = workspace_relative_path(STAGE_PACKAGE_MANIFEST);
+ let manifest = load_stage_package_manifest(&manifest_path)?;
+ let workspace_packages = workspace_package_names(&workspace_root_path())?;
+
+ validate_stage_package_entries(&manifest, &workspace_packages, &manifest_path)?;
+
+ Ok(())
+ }
+
+ #[test]
fn parses_manifest_dependencies_for_arch_policy() {
let manifest = r#"
[package]