ci: tighten stage 0 acceptance gates

This commit is contained in:
2026-06-25 03:45:23 +04:00
parent 27af3806b3
commit 5cc2c5819f
6 changed files with 424 additions and 137 deletions
+14 -10
View File
@@ -2,19 +2,21 @@ name: fparkan-ci
on: on:
push: push:
branches: [main] branches: [devel, main]
pull_request: pull_request:
branches: [main] branches: [devel, main]
workflow_dispatch:
jobs: jobs:
msrv-backend-neutral: msrv-backend-neutral:
name: MSRV backend-neutral crates name: MSRV backend-neutral crates
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@master - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with: with:
toolchain: 1.87.0 toolchain: 1.87.0
- name: Test backend-neutral crates - name: Test backend-neutral crates
@@ -48,6 +50,7 @@ jobs:
stage0-matrix: stage0-matrix:
name: Stage 0-2 CI (${{ matrix.os }}) name: Stage 0-2 CI (${{ matrix.os }})
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -61,12 +64,13 @@ jobs:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@master - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with: with:
toolchain-file: rust-toolchain.toml toolchain: 1.87.0
components: clippy,rustfmt
- name: Install cargo-deny - 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 - name: Run canonical CI gate
run: cargo xtask ci run: cargo xtask ci
- name: Record native Vulkan smoke status - name: Record native Vulkan smoke status
@@ -81,10 +85,10 @@ jobs:
--reason "native Vulkan smoke runner is not enabled on this CI lane yet" --reason "native Vulkan smoke runner is not enabled on this CI lane yet"
- name: Upload acceptance evidence - name: Upload acceptance evidence
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with: with:
name: stage-0-2-acceptance-${{ matrix.os }} name: stage-0-2-acceptance-${{ matrix.os }}
path: | path: |
target/fparkan/acceptance/stage-0-2-audit.json target/fparkan/acceptance/stage-0-2-audit.json
target/fparkan/native-smoke/*.json target/fparkan/native-smoke/*.json
if-no-files-found: ignore if-no-files-found: error
+39 -17
View File
@@ -345,14 +345,20 @@ impl VulkanLogicalDeviceProbe {
#[must_use] #[must_use]
pub fn graphics_queue(&self) -> vk::Queue { pub fn graphics_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device. // 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. /// Returns the presentation queue selected by the Stage 0 policy.
#[must_use] #[must_use]
pub fn present_queue(&self) -> vk::Queue { pub fn present_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device. // 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. /// Returns a shared reference to the live logical device.
@@ -440,8 +446,12 @@ pub enum VulkanSmokeRunError {
impl std::fmt::Display for VulkanSmokeRunError { impl std::fmt::Display for VulkanSmokeRunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::AcquireImage { result } => write!(f, "failed to acquire swapchain image: {result}"), Self::AcquireImage { result } => {
Self::PresentImage { result } => write!(f, "failed to present swapchain image: {result}"), write!(f, "failed to acquire swapchain image: {result}")
}
Self::PresentImage { result } => {
write!(f, "failed to present swapchain image: {result}")
}
Self::RecreateSwapchain { result } => { Self::RecreateSwapchain { result } => {
write!(f, "failed to recreate swapchain: {result}") write!(f, "failed to recreate swapchain: {result}")
} }
@@ -464,9 +474,11 @@ pub fn run_vulkan_smoke_pass(
let timeout_ns = u64::MAX; let timeout_ns = u64::MAX;
let image_available = vk::SemaphoreCreateInfo::default(); let image_available = vk::SemaphoreCreateInfo::default();
let image_ready = unsafe { device.device().create_semaphore(&image_available, None) } let image_ready =
.map_err(|error| VulkanSmokeRunError::RecreateSwapchain { unsafe { device.device().create_semaphore(&image_available, None) }.map_err(|error| {
result: format!("{error:?}"), VulkanSmokeRunError::RecreateSwapchain {
result: format!("{error:?}"),
}
})?; })?;
let recreate_interval = if recreate_count == 0 { let recreate_interval = if recreate_count == 0 {
@@ -479,18 +491,27 @@ pub fn run_vulkan_smoke_pass(
let mut created = 0_u32; let mut created = 0_u32;
for frame in 0..frames { for frame in 0..frames {
if recreate_interval > 0 && frame > 0 && frame % recreate_interval == 0 && created < recreate_count { if recreate_interval > 0
swapchain = create_vulkan_swapchain_probe(instance, surface, device) && frame > 0
.map_err(|error| VulkanSmokeRunError::RecreateSwapchain { && frame % recreate_interval == 0
result: error.to_string(), && 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); created = created.saturating_add(1);
} }
let image_index = unsafe { let image_index = unsafe {
swapchain swapchain.loader().acquire_next_image(
.loader() swapchain.swapchain(),
.acquire_next_image(swapchain.swapchain(), timeout_ns, image_ready, vk::Fence::null()) timeout_ns,
image_ready,
vk::Fence::null(),
)
} }
.map(|(index, _)| index) .map(|(index, _)| index)
.map_err(|error| VulkanSmokeRunError::AcquireImage { .map_err(|error| VulkanSmokeRunError::AcquireImage {
@@ -513,10 +534,11 @@ pub fn run_vulkan_smoke_pass(
result: format!("{error:?}"), result: format!("{error:?}"),
})?; })?;
unsafe { device.device().queue_wait_idle(render_queue) } unsafe { device.device().queue_wait_idle(render_queue) }.map_err(|error| {
.map_err(|error| VulkanSmokeRunError::PresentImage { VulkanSmokeRunError::PresentImage {
result: format!("{error:?}"), result: format!("{error:?}"),
})?; }
})?;
swaps = swaps.saturating_add(1); swaps = swaps.saturating_add(1);
} }
+12 -7
View File
@@ -16,8 +16,8 @@ use fparkan_platform_winit::{probe_smoke_window, WinitWindowPlan};
use fparkan_render_vulkan::{ use fparkan_render_vulkan::{
create_vulkan_instance_probe, create_vulkan_logical_device_probe, create_vulkan_surface_probe, create_vulkan_instance_probe, create_vulkan_logical_device_probe, create_vulkan_surface_probe,
create_vulkan_swapchain_probe, probe_vulkan_loader, run_vulkan_smoke_pass, create_vulkan_swapchain_probe, probe_vulkan_loader, run_vulkan_smoke_pass,
triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, VulkanInstanceProbe,
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSwapchainProbe, VulkanLogicalDeviceProbe, VulkanSwapchainProbe,
}; };
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; 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()); return Err("passed native smoke report requires frames to be advanced".to_string());
} }
if smoke_run.validation_error_count 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)?; let report = render_smoke_report_json(&options, &bootstrap)?;
@@ -443,12 +447,13 @@ impl VulkanBootstrapProbe {
options: &SmokeOptions, options: &SmokeOptions,
instance: &VulkanInstanceProbe, instance: &VulkanInstanceProbe,
window_handles: Option<NativeWindowHandles>, window_handles: Option<NativeWindowHandles>,
) -> Option<fparkan_render_vulkan::VulkanSurfaceProbe> ) -> Option<fparkan_render_vulkan::VulkanSurfaceProbe> {
{
if options.probes.vulkan.includes_surface() if options.probes.vulkan.includes_surface()
&& self.instance_status == VulkanInstanceStatus::Created && 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) => { Ok(surface) => {
self.surface_status = VulkanSurfaceStatus::Created; self.surface_status = VulkanSurfaceStatus::Created;
return Some(surface); return Some(surface);
+35
View File
@@ -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"]
+47
View File
@@ -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",
]
+277 -103
View File
@@ -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_ROADMAP: &str = "fixtures/acceptance/stage_0_2_roadmap.md";
const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv"; const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv";
const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json"; 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 REQUIRED_NATIVE_SMOKE_PLATFORMS: &[&str] = &["linux", "macos", "windows"];
const APPROVED_REGISTRY_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; 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 SUPPLY_CHAIN_BANNED_PACKAGES: &[&str] = &["native-tls", "openssl", "openssl-sys"];
const PINNED_RUST_TOOLCHAIN: &str = "1.87.0"; const PINNED_RUST_TOOLCHAIN: &str = "1.87.0";
const WORKSPACE_MSRV: &str = "1.87"; 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() { fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>(); 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> { fn run_cargo_deny() -> Result<(), String> {
let cargo_deny = std::env::var_os("CARGO_DENY").unwrap_or_else(|| "cargo-deny".into()); let cargo_deny = std::env::var_os("CARGO_DENY").unwrap_or_else(|| "cargo-deny".into());
let available = Command::new(&cargo_deny).arg("--version").status(); let version_output = Command::new(&cargo_deny)
match available { .arg("--version")
Ok(status) if status.success() => {} .output()
Ok(_) | Err(_) => { .map_err(|err| {
eprintln!("cargo-deny is unavailable; running built-in supply-chain policy fallback"); format!(
return run_builtin_supply_chain_policy(Path::new(".")); "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) 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> { fn run_builtin_supply_chain_policy(root: &Path) -> Result<(), String> {
let mut failures = Vec::new(); let mut failures = Vec::new();
validate_workspace_license(root, &mut failures)?; validate_workspace_license(root, &mut failures)?;
@@ -473,12 +515,7 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
if !manifest.exists() { if !manifest.exists() {
return Ok(()); return Ok(());
} }
let metadata = MetadataCommand::new() let metadata = workspace_metadata(root)?;
.manifest_path(&manifest)
.no_deps()
.other_options(["--offline".to_string(), "--locked".to_string()])
.exec()
.map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error))?;
if metadata.workspace_members.is_empty() { if metadata.workspace_members.is_empty() {
failures.push(format!( failures.push(format!(
"{}: cargo metadata produced no workspace members", "{}: cargo metadata produced no workspace members",
@@ -486,6 +523,18 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
)); ));
return Ok(()); 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(()) Ok(())
} }
@@ -1185,40 +1234,6 @@ enum Stage {
Number(u8), 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 { impl Stage {
fn parse(value: &str) -> Result<Self, String> { fn parse(value: &str) -> Result<Self, String> {
if value == "all" { 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> { fn parse_audit_options(args: &[String]) -> Result<AuditOptions, String> {
let mut roadmap = PathBuf::from("FPARKAN_ARCHITECTURE_ROADMAP_STAGES_0_5.md"); let mut roadmap = workspace_relative_path(CI_ACCEPTANCE_ROADMAP);
let mut coverage = PathBuf::from("fixtures/acceptance/coverage.tsv"); let mut coverage = workspace_relative_path(CI_ACCEPTANCE_COVERAGE);
let mut out = PathBuf::from("target") let mut out = workspace_root_path()
.join("target")
.join("fparkan") .join("fparkan")
.join("reports") .join("reports")
.join("acceptance") .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()))?; .map_err(|err| format!("{}: {err}", path.display()))?;
let platform = json_string_field(&json, "platform") let platform = json_string_field(&json, "platform")
.map_err(|err| format!("{}: {err}", path.display()))?; .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) Ok(reports)
} }
fn audit_native_smoke_reports(reports: &BTreeMap<String, serde_json::Value>) -> Vec<String> { fn audit_native_smoke_reports(reports: &BTreeMap<String, serde_json::Value>) -> Vec<String> {
let mut failures = Vec::new(); let mut failures = Vec::new();
let mut commit_shas = BTreeSet::new();
let mut rust_toolchains = BTreeSet::new();
for platform in REQUIRED_NATIVE_SMOKE_PLATFORMS { for platform in REQUIRED_NATIVE_SMOKE_PLATFORMS {
let Some(report) = reports.get(*platform) else { let Some(report) = reports.get(*platform) else {
failures.push(format!("{platform}: missing native smoke report")); failures.push(format!("{platform}: missing native smoke report"));
continue; continue;
}; };
validate_native_smoke_report(platform, report, &mut failures); 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() { for platform in reports.keys() {
if !REQUIRED_NATIVE_SMOKE_PLATFORMS.contains(&platform.as_str()) { if !REQUIRED_NATIVE_SMOKE_PLATFORMS.contains(&platform.as_str()) {
failures.push(format!("{platform}: unexpected native smoke platform")); 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 failures
} }
@@ -1561,7 +1607,14 @@ fn validate_native_smoke_report(
expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures); expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures);
expect_u64_field(platform, report, "validation_error_count", 0, failures); expect_u64_field(platform, report, "validation_error_count", 0, failures);
expect_nonempty_string(platform, report, "commit_sha", 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, "target_triple", failures);
expect_nonempty_string(platform, report, "shader_manifest_hash", failures); expect_nonempty_string(platform, report, "shader_manifest_hash", failures);
expect_nonempty_string(platform, report, "vulkan_device_name", failures); expect_nonempty_string(platform, report, "vulkan_device_name", failures);
@@ -1741,13 +1794,20 @@ impl AcceptanceAudit {
self.partial self.partial
.iter() .iter()
.chain(&self.blocked) .chain(&self.blocked)
.chain(&self.omitted)
.chain(&self.missing) .chain(&self.missing)
.cloned() .cloned()
.collect() .collect()
} }
fn strict_failures(&self) -> Vec<String> { 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 { AcceptanceAudit {
commit_sha: current_git_commit_sha(), commit_sha: current_git_commit_sha(),
rust_toolchain: PINNED_RUST_TOOLCHAIN.to_string(), rust_toolchain: measured_rust_toolchain_version(),
msrv: WORKSPACE_MSRV.to_string(), msrv: WORKSPACE_MSRV.to_string(),
required_total: required.len(), required_total: required.len(),
covered, covered,
@@ -1930,6 +1990,23 @@ fn current_git_commit_sha() -> String {
.unwrap_or_else(|| "unknown".to_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 { fn render_string_usize_map(values: &BTreeMap<String, usize>) -> String {
let pairs = values let pairs = values
.iter() .iter()
@@ -1994,6 +2071,7 @@ fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> {
fn render_acceptance_report(options: &AcceptanceOptions) -> String { fn render_acceptance_report(options: &AcceptanceOptions) -> String {
let packages = stage_report_packages(options.stage) let packages = stage_report_packages(options.stage)
.unwrap_or_default()
.into_iter() .into_iter()
.map(|package| format!(" \"{package}\"")) .map(|package| format!(" \"{package}\""))
.collect::<Vec<_>>() .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 { match stage {
Stage::All => ALL_WORKSPACE_PACKAGES.to_vec(), Stage::All => workspace_package_names(&workspace_root)
Stage::Number(number) => stage_packages(number).unwrap_or(&[]).to_vec(), .map(|packages| packages.into_iter().collect::<Vec<_>>()),
Stage::Number(number) => stage_packages(number),
} }
} }
@@ -2052,12 +2132,19 @@ fn run_stage_tests(
} }
Stage::Number(number) => { Stage::Number(number) => {
for package in stage_packages(number)? { for package in stage_packages(number)? {
let mut args = vec!["test", "-p", package, "--locked", "--offline"]; let mut args = vec![
args.extend(suffix.iter().copied()); "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 { 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 { } else {
cargo(&args)?; cargo_owned(&args)?;
} }
} }
Ok(()) Ok(())
@@ -2065,43 +2152,108 @@ fn run_stage_tests(
} }
} }
fn stage_packages(stage: u8) -> Result<&'static [&'static str], String> { fn stage_packages(stage: u8) -> Result<Vec<String>, String> {
match stage { let manifest_path = workspace_relative_path(STAGE_PACKAGE_MANIFEST);
0 => Ok(&[ let manifest = load_stage_package_manifest(&manifest_path)?;
"fparkan-corpus", let packages = manifest
"fparkan-diagnostics", .stages
"fparkan-test-support", .get(&stage.to_string())
]), .cloned()
1 => Ok(&[ .ok_or_else(|| format!("stage out of range: {stage}"))?;
"fparkan-binary", validate_stage_package_entries(
"fparkan-path", &manifest,
"fparkan-nres", &workspace_package_names(&workspace_root_path())?,
"fparkan-rsli", &manifest_path,
"fparkan-resource", )?;
"fparkan-vfs", Ok(packages)
]), }
2 => Ok(&["fparkan-prototype"]),
3 => Ok(&[ fn workspace_package_names(root: &Path) -> Result<BTreeSet<String>, String> {
"fparkan-msh", let metadata = workspace_metadata(root)?;
"fparkan-material", Ok(metadata
"fparkan-texm", .workspace_packages()
"fparkan-assets", .iter()
"fparkan-render", .map(|package| package.name.to_string())
"fparkan-viewer", .collect())
]), }
4 => Ok(&["fparkan-animation", "fparkan-fx"]),
5 => Ok(&[ fn workspace_metadata(root: &Path) -> Result<cargo_metadata::Metadata, String> {
"fparkan-terrain-format", let manifest = root.join("Cargo.toml");
"fparkan-terrain", MetadataCommand::new()
"fparkan-mission-format", .manifest_path(&manifest)
"fparkan-world", .no_deps()
"fparkan-runtime", .other_options(["--offline".to_string(), "--locked".to_string()])
"fparkan-headless", .exec()
"fparkan-game", .map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error))
"fparkan-vulkan-smoke", }
]),
_ => Err(format!("stage out of range: {stage}")), #[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)] #[cfg(test)]
@@ -2233,6 +2385,10 @@ mod tests {
assert_eq!(audit.missing, ["S0-ARCH-002"]); assert_eq!(audit.missing, ["S0-ARCH-002"]);
assert_eq!(audit.unknown_coverage, ["S9-UNKNOWN-001"]); assert_eq!(audit.unknown_coverage, ["S9-UNKNOWN-001"]);
assert_eq!(audit.by_stage.get("S0"), Some(&2)); 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] #[test]
@@ -2273,7 +2429,7 @@ mod tests {
serde_json::json!({ serde_json::json!({
"schema_version": "fparkan-native-smoke-v1", "schema_version": "fparkan-native-smoke-v1",
"commit_sha": "0123456789abcdef0123456789abcdef01234567", "commit_sha": "0123456789abcdef0123456789abcdef01234567",
"rust_toolchain": "1.87.0", "rust_toolchain": measured_rust_toolchain_version(),
"target_triple": format!("{platform}-test-target"), "target_triple": format!("{platform}-test-target"),
"platform": platform, "platform": platform,
"status": "passed", "status": "passed",
@@ -2311,7 +2467,7 @@ mod tests {
serde_json::json!({ serde_json::json!({
"schema_version": "fparkan-native-smoke-v1", "schema_version": "fparkan-native-smoke-v1",
"commit_sha": "0123456789abcdef0123456789abcdef01234567", "commit_sha": "0123456789abcdef0123456789abcdef01234567",
"rust_toolchain": "1.87.0", "rust_toolchain": measured_rust_toolchain_version(),
"target_triple": "aarch64-apple-darwin", "target_triple": "aarch64-apple-darwin",
"platform": "macos", "platform": "macos",
"status": "blocked", "status": "blocked",
@@ -2412,13 +2568,31 @@ mod tests {
#[test] #[test]
fn maps_stage_packages() { fn maps_stage_packages() {
assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-assets"))); assert!(stage_packages(0)
assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-viewer"))); .is_ok_and(|packages| packages.contains(&"fparkan-platform".to_string())));
assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-runtime"))); assert!(stage_packages(0)
assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-game"))); .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())); 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] #[test]
fn parses_manifest_dependencies_for_arch_policy() { fn parses_manifest_dependencies_for_arch_policy() {
let manifest = r#" let manifest = r#"