From 12858f14bde7849d19f15737cf5fee292a3df0f9 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 18 Feb 2026 18:59:56 +0800 Subject: [PATCH 1/5] ci: add dependency-aware test target selection Add `cargo xtask affected` command that analyzes git changes against a base ref and determines which test targets (QEMU aarch64/x86_64, board phytiumpi/rk3568) actually need to run. The analysis works in three phases: 1. git diff detects changed files 2. cargo metadata builds the workspace reverse dependency graph, then BFS propagates changes to all affected crates 3. Path-based and crate-based rules map affected crates to concrete test targets Update CI workflows (test-qemu.yml, test-board.yml) to run a lightweight `detect` job on ubuntu-latest before dispatching actual tests on self-hosted hardware runners, skipping targets unaffected by the change. Co-authored-by: Cursor --- .github/workflows/test-board.yml | 80 +++++- .github/workflows/test-qemu.yml | 83 +++++-- xtask/src/affected.rs | 401 +++++++++++++++++++++++++++++++ xtask/src/main.rs | 13 + 4 files changed, 547 insertions(+), 30 deletions(-) create mode 100644 xtask/src/affected.rs diff --git a/.github/workflows/test-board.yml b/.github/workflows/test-board.yml index 3719e76e..6258c041 100644 --- a/.github/workflows/test-board.yml +++ b/.github/workflows/test-board.yml @@ -3,23 +3,77 @@ name: Test for BOARD on: [push, pull_request, workflow_dispatch] jobs: + detect: + name: "Detect affected targets" + runs-on: ubuntu-latest + outputs: + skip_all: ${{ steps.analyze.outputs.skip_all }} + board_phytiumpi: ${{ steps.analyze.outputs.board_phytiumpi }} + board_rk3568: ${{ steps.analyze.outputs.board_rk3568 }} + board_matrix: ${{ steps.matrix.outputs.board_matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: affected-${{ runner.os }}-${{ hashFiles('xtask/Cargo.toml') }} + + - name: Determine base ref + id: baseref + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "push" ]; then + if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" + else + echo "ref=HEAD~1" >> "$GITHUB_OUTPUT" + fi + else + echo "ref=origin/main" >> "$GITHUB_OUTPUT" + fi + + - name: Analyze affected targets + id: analyze + run: cargo xtask affected --base "${{ steps.baseref.outputs.ref }}" + + - name: Build board test matrix + id: matrix + run: | + MATRIX="[]" + if [ "${{ steps.analyze.outputs.board_phytiumpi }}" = "true" ]; then + MATRIX=$(echo "$MATRIX" | jq -c '. + [ + {"board":"phytiumpi","vmconfigs":"configs/vms/arceos-aarch64-e2000-smp1.toml","vmconfigs_name":"ArceOS"}, + {"board":"phytiumpi","vmconfigs":"configs/vms/linux-aarch64-e2000-smp1.toml","vmconfigs_name":"Linux"} + ]') + fi + if [ "${{ steps.analyze.outputs.board_rk3568 }}" = "true" ]; then + MATRIX=$(echo "$MATRIX" | jq -c '. + [ + {"board":"roc-rk3568-pc","vmconfigs":"configs/vms/arceos-aarch64-rk3568-smp1.toml","vmconfigs_name":"ArceOS"}, + {"board":"roc-rk3568-pc","vmconfigs":"configs/vms/linux-aarch64-rk3568-smp1.toml","vmconfigs_name":"Linux"} + ]') + fi + echo "board_matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "Generated board matrix: $MATRIX" + test-board: + needs: detect + if: needs.detect.outputs.skip_all != 'true' && needs.detect.outputs.board_matrix != '[]' name: "Test board: ${{ matrix.board }} - ${{ matrix.vmconfigs_name }}" strategy: matrix: - include: - - board: phytiumpi - vmconfigs: configs/vms/arceos-aarch64-e2000-smp1.toml - vmconfigs_name: ArceOS - - board: phytiumpi - vmconfigs: configs/vms/linux-aarch64-e2000-smp1.toml - vmconfigs_name: Linux - - board: roc-rk3568-pc - vmconfigs: configs/vms/arceos-aarch64-rk3568-smp1.toml - vmconfigs_name: ArceOS - - board: roc-rk3568-pc - vmconfigs: configs/vms/linux-aarch64-rk3568-smp1.toml - vmconfigs_name: Linux + include: ${{ fromJson(needs.detect.outputs.board_matrix) }} fail-fast: false runs-on: - self-hosted diff --git a/.github/workflows/test-qemu.yml b/.github/workflows/test-qemu.yml index 8ba4c1e5..3f2650af 100644 --- a/.github/workflows/test-qemu.yml +++ b/.github/workflows/test-qemu.yml @@ -3,27 +3,76 @@ name: Test for QEMU on: [push, pull_request, workflow_dispatch] jobs: + detect: + name: "Detect affected targets" + runs-on: ubuntu-latest + outputs: + skip_all: ${{ steps.analyze.outputs.skip_all }} + qemu_aarch64: ${{ steps.analyze.outputs.qemu_aarch64 }} + qemu_x86_64: ${{ steps.analyze.outputs.qemu_x86_64 }} + qemu_matrix: ${{ steps.matrix.outputs.qemu_matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: affected-${{ runner.os }}-${{ hashFiles('xtask/Cargo.toml') }} + + - name: Determine base ref + id: baseref + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "push" ]; then + if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" + else + echo "ref=HEAD~1" >> "$GITHUB_OUTPUT" + fi + else + echo "ref=origin/main" >> "$GITHUB_OUTPUT" + fi + + - name: Analyze affected targets + id: analyze + run: cargo xtask affected --base "${{ steps.baseref.outputs.ref }}" + + - name: Build QEMU test matrix + id: matrix + run: | + MATRIX="[]" + if [ "${{ steps.analyze.outputs.qemu_aarch64 }}" = "true" ]; then + MATRIX=$(echo "$MATRIX" | jq -c '. + [ + {"arch":"aarch64","vmconfigs":"configs/vms/arceos-aarch64-qemu-smp1.toml","vmconfigs_name":"ArceOS","vmimage_name":"qemu_aarch64_arceos"}, + {"arch":"aarch64","vmconfigs":"configs/vms/linux-aarch64-qemu-smp1.toml","vmconfigs_name":"Linux","vmimage_name":"qemu_aarch64_linux"} + ]') + fi + if [ "${{ steps.analyze.outputs.qemu_x86_64 }}" = "true" ]; then + MATRIX=$(echo "$MATRIX" | jq -c '. + [ + {"arch":"x86_64","vmconfigs":"configs/vms/nimbos-x86_64-qemu-smp1.toml","vmconfigs_name":"NimbOS","vmimage_name":"qemu_x86_64_nimbos"} + ]') + fi + echo "qemu_matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "Generated QEMU matrix: $MATRIX" + test-qemu: + needs: detect + if: needs.detect.outputs.skip_all != 'true' && needs.detect.outputs.qemu_matrix != '[]' name: "Test qemu: ${{ matrix.arch }} - ${{ matrix.vmconfigs_name }}" strategy: matrix: - include: - - arch: aarch64 - vmconfigs: configs/vms/arceos-aarch64-qemu-smp1.toml - vmconfigs_name: ArceOS - vmimage_name: qemu_aarch64_arceos - - arch: aarch64 - vmconfigs: configs/vms/linux-aarch64-qemu-smp1.toml - vmconfigs_name: Linux - vmimage_name: qemu_aarch64_linux - # - arch: riscv64 - # vmconfigs: configs/vms/arceos-riscv64-qemu-smp1.toml - # vmconfigs_name: ArceOS - # vmimage_name: qemu_arceos_riscv64 - - arch: x86_64 - vmconfigs: configs/vms/nimbos-x86_64-qemu-smp1.toml - vmconfigs_name: NimbOS - vmimage_name: qemu_x86_64_nimbos + include: ${{ fromJson(needs.detect.outputs.qemu_matrix) }} fail-fast: false runs-on: - self-hosted diff --git a/xtask/src/affected.rs b/xtask/src/affected.rs new file mode 100644 index 00000000..292f9468 --- /dev/null +++ b/xtask/src/affected.rs @@ -0,0 +1,401 @@ +// Copyright 2025 The Axvisor Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Dependency-aware test scope analysis. +//! +//! Determines which test targets (QEMU configurations, development boards) need +//! to run based on the files changed in a git commit or pull request. +//! +//! The analysis works in three phases: +//! 1. **File detection**: `git diff` identifies changed files +//! 2. **Dependency propagation**: `cargo metadata` builds the workspace dependency +//! graph, then a reverse BFS finds all transitively affected crates +//! 3. **Target mapping**: Changed files and affected crates are mapped to concrete +//! test targets using path-based and crate-based rules + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io::Write; +use std::process::Command; + +use anyhow::{Context, Result}; +use cargo_metadata::MetadataCommand; +use serde::Serialize; + +/// Boolean flags indicating which test targets should run. +#[derive(Debug, Default, Serialize)] +pub struct TestScope { + pub skip_all: bool, + pub qemu_aarch64: bool, + pub qemu_x86_64: bool, + pub board_phytiumpi: bool, + pub board_rk3568: bool, + pub changed_crates: Vec, + pub affected_crates: Vec, +} + +impl TestScope { + fn all() -> Self { + Self { + qemu_aarch64: true, + qemu_x86_64: true, + board_phytiumpi: true, + board_rk3568: true, + ..Default::default() + } + } + + fn enable_all_aarch64(&mut self) { + self.qemu_aarch64 = true; + self.board_phytiumpi = true; + self.board_rk3568 = true; + } + + fn any_enabled(&self) -> bool { + self.qemu_aarch64 || self.qemu_x86_64 || self.board_phytiumpi || self.board_rk3568 + } +} + +type CrateMap = HashMap; +type ReverseDeps = HashMap>; + +/// Entry point: analyze changes against `base_ref` and print the result. +pub fn run(base_ref: &str) -> Result<()> { + let scope = analyze(base_ref)?; + + // Write to $GITHUB_OUTPUT when running inside GitHub Actions. + if let Ok(path) = std::env::var("GITHUB_OUTPUT") { + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .with_context(|| format!("Failed to open GITHUB_OUTPUT at {path}"))?; + writeln!(file, "skip_all={}", scope.skip_all)?; + writeln!(file, "qemu_aarch64={}", scope.qemu_aarch64)?; + writeln!(file, "qemu_x86_64={}", scope.qemu_x86_64)?; + writeln!(file, "board_phytiumpi={}", scope.board_phytiumpi)?; + writeln!(file, "board_rk3568={}", scope.board_rk3568)?; + } + + println!("{}", serde_json::to_string_pretty(&scope)?); + Ok(()) +} + +fn analyze(base_ref: &str) -> Result { + let changed_files = get_changed_files(base_ref)?; + + eprintln!("[affected] changed files ({}):", changed_files.len()); + for f in &changed_files { + eprintln!(" {f}"); + } + + if changed_files.is_empty() { + eprintln!("[affected] no changes detected → skip all tests"); + return Ok(TestScope { skip_all: true, ..Default::default() }); + } + + let has_code_changes = changed_files.iter().any(|f| !is_non_code_file(f)); + if !has_code_changes { + eprintln!("[affected] only non-code files changed → skip all tests"); + return Ok(TestScope { skip_all: true, ..Default::default() }); + } + + // Phase 1 & 2: build dependency graph and propagate changes. + let (crate_map, reverse_deps) = build_workspace_graph()?; + let changed_crates = map_files_to_crates(&changed_files, &crate_map); + let affected_crates = find_all_affected(&changed_crates, &reverse_deps); + + eprintln!("[affected] directly changed crates: {:?}", changed_crates); + eprintln!("[affected] all affected crates: {:?}", affected_crates); + + // Phase 3: map to test targets. + let mut scope = determine_targets(&changed_files, &affected_crates); + scope.changed_crates = sorted_vec(&changed_crates); + scope.affected_crates = sorted_vec(&affected_crates); + + eprintln!("[affected] test scope: qemu_aarch64={} qemu_x86_64={} board_phytiumpi={} board_rk3568={}", + scope.qemu_aarch64, scope.qemu_x86_64, scope.board_phytiumpi, scope.board_rk3568); + + Ok(scope) +} + +// --------------------------------------------------------------------------- +// Phase 1: detect changed files +// --------------------------------------------------------------------------- + +fn get_changed_files(base_ref: &str) -> Result> { + let try_diff = |args: &[&str]| -> Option> { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + Some( + String::from_utf8(output.stdout) + .ok()? + .lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect(), + ) + }; + + // Try the requested base ref first, fall back to HEAD~1. + if let Some(files) = try_diff(&["diff", "--name-only", base_ref]) { + return Ok(files); + } + eprintln!("[affected] base ref '{base_ref}' not reachable, falling back to HEAD~1"); + + try_diff(&["diff", "--name-only", "HEAD~1"]) + .context("git diff failed for both the requested base ref and HEAD~1") +} + +fn is_non_code_file(path: &str) -> bool { + const SKIP_DIRS: &[&str] = &["doc/"]; + const SKIP_EXTS: &[&str] = &[".md", ".txt", ".png", ".jpg", ".jpeg", ".svg", ".gif"]; + const SKIP_FILES: &[&str] = &["LICENSE", ".gitignore", ".gitattributes"]; + + SKIP_DIRS.iter().any(|d| path.starts_with(d)) + || SKIP_EXTS.iter().any(|e| path.ends_with(e)) + || SKIP_FILES.iter().any(|f| path == *f) +} + +// --------------------------------------------------------------------------- +// Phase 2: workspace dependency graph & propagation +// --------------------------------------------------------------------------- + +fn build_workspace_graph() -> Result<(CrateMap, ReverseDeps)> { + let metadata = MetadataCommand::new() + .exec() + .context("cargo metadata failed")?; + + let ws_root = metadata.workspace_root.as_str(); + let ws_ids: HashSet<_> = metadata.workspace_members.iter().collect(); + + let mut crate_map = CrateMap::new(); + let mut id_to_name = HashMap::new(); + + for pkg in &metadata.packages { + if ws_ids.contains(&pkg.id) { + let dir = pkg + .manifest_path + .parent() + .unwrap() + .strip_prefix(ws_root) + .unwrap_or(pkg.manifest_path.parent().unwrap()) + .to_string(); + // Ensure the directory path ends with '/' for prefix matching. + let dir = if dir.is_empty() { String::new() } else { format!("{dir}/") }; + crate_map.insert(pkg.name.clone(), dir); + id_to_name.insert(pkg.id.clone(), pkg.name.clone()); + } + } + + let mut reverse_deps = ReverseDeps::new(); + if let Some(resolve) = &metadata.resolve { + for node in &resolve.nodes { + let Some(node_name) = id_to_name.get(&node.id) else { continue }; + for dep in &node.deps { + if let Some(dep_name) = id_to_name.get(&dep.pkg) { + reverse_deps + .entry(dep_name.clone()) + .or_default() + .insert(node_name.clone()); + } + } + } + } + + eprintln!("[affected] workspace crates: {:?}", crate_map.keys().collect::>()); + eprintln!("[affected] reverse deps:"); + for (k, v) in &reverse_deps { + eprintln!(" {k} ← {:?}", v); + } + + Ok((crate_map, reverse_deps)) +} + +fn map_files_to_crates(files: &[String], crate_map: &CrateMap) -> HashSet { + let mut result = HashSet::new(); + for file in files { + // Pick the longest matching prefix to handle nested crate directories. + let mut best: Option<&str> = None; + for (name, dir) in crate_map { + if !dir.is_empty() && file.starts_with(dir.as_str()) { + if best.is_none() || dir.len() > crate_map[best.unwrap()].len() { + best = Some(name.as_str()); + } + } + } + if let Some(name) = best { + result.insert(name.to_string()); + } + } + result +} + +fn find_all_affected(changed: &HashSet, reverse_deps: &ReverseDeps) -> HashSet { + let mut affected = changed.clone(); + let mut queue: VecDeque<_> = changed.iter().cloned().collect(); + + while let Some(current) = queue.pop_front() { + if let Some(dependents) = reverse_deps.get(¤t) { + for dep in dependents { + if affected.insert(dep.clone()) { + queue.push_back(dep.clone()); + } + } + } + } + affected +} + +// --------------------------------------------------------------------------- +// Phase 3: map affected crates + changed files → test targets +// --------------------------------------------------------------------------- + +fn determine_targets(changed_files: &[String], affected_crates: &HashSet) -> TestScope { + let mut scope = TestScope::default(); + + // ── Rule 1: root build config changes → run everything ── + if changed_files.iter().any(|f| { + matches!(f.as_str(), "Cargo.toml" | "Cargo.lock" | "rust-toolchain.toml") + }) { + return TestScope::all(); + } + + // ── Rule 2: build-tool (xtask) changes → run everything ── + if affected_crates.contains("xtask") { + return TestScope::all(); + } + + // ── Rule 3: core module changes → run everything ── + // axruntime and axconfig are foundational; a change propagates to all targets. + if ["axruntime", "axconfig"] + .iter() + .any(|c| affected_crates.contains(*c)) + { + return TestScope::all(); + } + + // ── Rule 4: kernel common code (non-arch-specific) → run everything ── + if changed_files.iter().any(|f| { + f.starts_with("kernel/") && !f.starts_with("kernel/src/hal/arch/") + }) { + return TestScope::all(); + } + + // ── Rule 5: architecture-specific kernel code ── + for file in changed_files { + if file.starts_with("kernel/src/hal/arch/aarch64/") { + scope.enable_all_aarch64(); + } + if file.starts_with("kernel/src/hal/arch/x86_64/") { + scope.qemu_x86_64 = true; + } + } + + // ── Rule 6: platform crate ── + if affected_crates.contains("axplat-x86-qemu-q35") { + scope.qemu_x86_64 = true; + } + + // ── Rule 7: filesystem module → targets with `fs` feature ── + if affected_crates.contains("axfs") { + scope.qemu_aarch64 = true; // linux guest uses rootfs + scope.board_phytiumpi = true; + scope.board_rk3568 = true; + } + + // ── Rule 8: driver module → board-specific analysis ── + if affected_crates.contains("driver") { + let phytium = changed_files.iter().any(|f| f.contains("phytium")); + let rockchip = changed_files + .iter() + .any(|f| f.contains("rockchip") || f.contains("rk3568")); + let common_driver = changed_files.iter().any(|f| { + f.starts_with("modules/driver/") + && !f.contains("phytium") + && !f.contains("rockchip") + && !f.contains("rk3568") + }); + + if common_driver { + scope.board_phytiumpi = true; + scope.board_rk3568 = true; + } + if phytium { + scope.board_phytiumpi = true; + } + if rockchip { + scope.board_rk3568 = true; + } + } + + // ── Rule 9: CI workflow / config file changes ── + for file in changed_files { + if file.starts_with(".github/workflows/") { + if file.contains("qemu") { + scope.qemu_aarch64 = true; + scope.qemu_x86_64 = true; + } + if file.contains("board") || file.contains("uboot") { + scope.board_phytiumpi = true; + scope.board_rk3568 = true; + } + } + } + + // ── Rule 10: board / VM config file changes ── + for file in changed_files { + if file.starts_with("configs/board/") { + if file.contains("qemu-aarch64") { + scope.qemu_aarch64 = true; + } + if file.contains("qemu-x86_64") { + scope.qemu_x86_64 = true; + } + if file.contains("phytiumpi") { + scope.board_phytiumpi = true; + } + if file.contains("roc-rk3568") { + scope.board_rk3568 = true; + } + } + if file.starts_with("configs/vms/") { + if file.contains("aarch64") { + scope.qemu_aarch64 = true; + if file.contains("e2000") { + scope.board_phytiumpi = true; + } + if file.contains("rk3568") { + scope.board_rk3568 = true; + } + } + if file.contains("x86_64") { + scope.qemu_x86_64 = true; + } + } + } + + // If nothing was enabled after all rules, treat as "skip all". + if !scope.any_enabled() { + scope.skip_all = true; + } + + scope +} + +fn sorted_vec(set: &HashSet) -> Vec { + let mut v: Vec<_> = set.iter().cloned().collect(); + v.sort(); + v +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7aee9ab4..b9a3a86e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -22,6 +22,7 @@ use clap::{Args, Parser, Subcommand}; use std::fs; use std::path::{Path, PathBuf}; +mod affected; mod cargo; mod clippy; mod ctx; @@ -62,6 +63,8 @@ enum Commands { Image(image::ImageArgs), /// Manage local devspace dependencies Devspace(DevspaceArgs), + /// Analyze which test targets are affected by recent changes + Affected(AffectedArgs), } #[derive(Parser)] @@ -149,6 +152,13 @@ enum DevspaceCommand { Stop, } +#[derive(Parser)] +struct AffectedArgs { + /// Git ref to diff against (e.g. origin/main, HEAD~1, a commit SHA) + #[arg(long, default_value = "origin/main")] + base: String, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -193,6 +203,9 @@ async fn main() -> Result<()> { DevspaceCommand::Start => devspace::start()?, DevspaceCommand::Stop => devspace::stop()?, }, + Commands::Affected(args) => { + affected::run(&args.base)?; + } } Ok(()) From f6ef6f66aca2f957472ce516b084835c9b1214ea Mon Sep 17 00:00:00 2001 From: yoinspiration Date: Wed, 18 Feb 2026 20:15:53 +0800 Subject: [PATCH 2/5] fix: use to_string() for cargo_metadata PackageName type cargo_metadata 0.23 returns PackageName instead of String for package names. Convert with to_string() to match HashMap key types. Co-authored-by: Cursor --- xtask/src/affected.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xtask/src/affected.rs b/xtask/src/affected.rs index 292f9468..e27064bc 100644 --- a/xtask/src/affected.rs +++ b/xtask/src/affected.rs @@ -194,8 +194,8 @@ fn build_workspace_graph() -> Result<(CrateMap, ReverseDeps)> { .to_string(); // Ensure the directory path ends with '/' for prefix matching. let dir = if dir.is_empty() { String::new() } else { format!("{dir}/") }; - crate_map.insert(pkg.name.clone(), dir); - id_to_name.insert(pkg.id.clone(), pkg.name.clone()); + crate_map.insert(pkg.name.to_string(), dir); + id_to_name.insert(pkg.id.clone(), pkg.name.to_string()); } } @@ -206,9 +206,9 @@ fn build_workspace_graph() -> Result<(CrateMap, ReverseDeps)> { for dep in &node.deps { if let Some(dep_name) = id_to_name.get(&dep.pkg) { reverse_deps - .entry(dep_name.clone()) + .entry(dep_name.to_string()) .or_default() - .insert(node_name.clone()); + .insert(node_name.to_string()); } } } From 7f7f88bf927397eb82b91545b63c0cbce5410719 Mon Sep 17 00:00:00 2001 From: yoinspiration Date: Wed, 18 Feb 2026 20:18:14 +0800 Subject: [PATCH 3/5] fix: refine core module rule to check direct changes only The previous logic treated axruntime/axconfig as "all tests needed" whenever they appeared in the affected set. This caused false positives for target-specific deps (e.g. axplat-x86-qemu-q35 is a cfg(x86_64) dep of axruntime). Now only trigger all tests when these core modules are directly modified. Co-authored-by: Cursor --- xtask/src/affected.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/xtask/src/affected.rs b/xtask/src/affected.rs index e27064bc..27699744 100644 --- a/xtask/src/affected.rs +++ b/xtask/src/affected.rs @@ -118,7 +118,7 @@ fn analyze(base_ref: &str) -> Result { eprintln!("[affected] all affected crates: {:?}", affected_crates); // Phase 3: map to test targets. - let mut scope = determine_targets(&changed_files, &affected_crates); + let mut scope = determine_targets(&changed_files, &changed_crates, &affected_crates); scope.changed_crates = sorted_vec(&changed_crates); scope.affected_crates = sorted_vec(&affected_crates); @@ -262,7 +262,11 @@ fn find_all_affected(changed: &HashSet, reverse_deps: &ReverseDeps) -> H // Phase 3: map affected crates + changed files → test targets // --------------------------------------------------------------------------- -fn determine_targets(changed_files: &[String], affected_crates: &HashSet) -> TestScope { +fn determine_targets( + changed_files: &[String], + changed_crates: &HashSet, + affected_crates: &HashSet, +) -> TestScope { let mut scope = TestScope::default(); // ── Rule 1: root build config changes → run everything ── @@ -273,15 +277,18 @@ fn determine_targets(changed_files: &[String], affected_crates: &HashSet } // ── Rule 2: build-tool (xtask) changes → run everything ── - if affected_crates.contains("xtask") { + if changed_crates.contains("xtask") { return TestScope::all(); } - // ── Rule 3: core module changes → run everything ── - // axruntime and axconfig are foundational; a change propagates to all targets. + // ── Rule 3: core module *directly* changed → run everything ── + // axruntime and axconfig are foundational. Only trigger all tests when their + // source code is directly modified, not when they are transitively affected + // by a platform-specific crate (e.g. axplat-x86-qemu-q35 is a target-cfg dep + // of axruntime, but a change there should only require x86 testing). if ["axruntime", "axconfig"] .iter() - .any(|c| affected_crates.contains(*c)) + .any(|c| changed_crates.contains(*c)) { return TestScope::all(); } From 5717a2ba7f6493d42ecb0bdde7b9429e6413ba58 Mon Sep 17 00:00:00 2001 From: yoinspiration Date: Wed, 18 Feb 2026 20:20:58 +0800 Subject: [PATCH 4/5] docs: add design document for dependency-aware testing Co-authored-by: Cursor --- doc/dependency-aware-testing.md | 227 ++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 doc/dependency-aware-testing.md diff --git a/doc/dependency-aware-testing.md b/doc/dependency-aware-testing.md new file mode 100644 index 00000000..3dc5b015 --- /dev/null +++ b/doc/dependency-aware-testing.md @@ -0,0 +1,227 @@ +# 依赖感知的测试目标选择 + +## 背景与动机 + +AxVisor 是一个运行在多种硬件平台上的 Hypervisor,其集成测试需要在 QEMU 模拟器和真实开发板上执行。在此之前,每次代码提交(push/PR)都会触发**全部**测试配置(QEMU aarch64、QEMU x86_64、飞腾派、RK3568),即使只修改了一行文档或某个板级驱动也是如此。 + +这带来了两个问题: + +1. **硬件资源浪费**:自托管 Runner 连接的开发板是稀缺资源,不必要的测试会阻塞其他任务。 +2. **反馈延迟**:全量测试耗时长,开发者等待时间增加。 + +与此同时,AxVisor 采用 Cargo workspace 组织多个 crate,crate 之间存在依赖关系。当一个底层模块(如 `axruntime`)被修改时,所有依赖它的上层模块都应该被重新测试——这就是**依赖感知测试**的核心需求。 + +## 设计概述 + +### 三阶段分析流程 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 阶段 1:变更检测 │ +│ git diff --name-only │ +│ → 获取变更文件列表 │ +│ → 过滤非代码文件(文档、图片等) │ +└─────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 阶段 2:依赖传播 │ +│ cargo metadata → 构建 workspace 反向依赖图 │ +│ BFS 遍历 → 找出所有间接受影响的 crate │ +└─────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 阶段 3:目标映射 │ +│ 10 条规则将受影响的 crate + 变更文件 │ +│ 映射到具体的测试目标(QEMU/开发板) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Workspace 内部依赖图 + +通过 `cargo metadata` 自动提取的 workspace 内部反向依赖关系: + +``` +axconfig ← axruntime, axvisor +axruntime ← axvisor +axfs ← (axruntime 间接依赖) +driver ← axvisor +axplat-x86-qemu-q35 ← axruntime (仅 x86_64 目标) +``` + +当某个 crate 被修改时,沿着反向依赖链向上传播。例如: + +- 修改 `axconfig` → `axruntime` 受影响 → `axvisor` 受影响 +- 修改 `driver` → `axvisor` 受影响 +- 修改 `axplat-x86-qemu-q35` → `axruntime` 受影响(但这是条件编译依赖,仅 x86_64) + +### 测试目标 + +| 目标 ID | 说明 | Runner 标签 | +|---------|------|-------------| +| `qemu_aarch64` | QEMU AArch64 模拟测试 | `[self-hosted, linux, intel]` | +| `qemu_x86_64` | QEMU x86_64 模拟测试 | `[self-hosted, linux, intel]` | +| `board_phytiumpi` | 飞腾派开发板测试 | `[self-hosted, linux, phytiumpi]` | +| `board_rk3568` | ROC-RK3568-PC 开发板测试 | `[self-hosted, linux, roc-rk3568-pc]` | + +## 映射规则 + +分析引擎按以下 10 条规则(优先级从高到低)将变更映射到测试目标: + +### 全量触发规则(返回所有目标) + +| 规则 | 触发条件 | 理由 | +|------|----------|------| +| Rule 1 | 根构建配置变更:`Cargo.toml`、`Cargo.lock`、`rust-toolchain.toml` | 依赖或工具链变更影响所有构建 | +| Rule 2 | `xtask/` 源码被**直接修改** | 构建工具变更可能影响所有构建流程 | +| Rule 3 | `axruntime` 或 `axconfig` 被**直接修改** | 核心基础模块,所有平台都依赖 | +| Rule 4 | `kernel/` 下非架构特定的代码变更(不在 `kernel/src/hal/arch/` 下) | VMM、Shell、调度等通用逻辑 | + +### 精确触发规则 + +| 规则 | 触发条件 | 触发目标 | +|------|----------|----------| +| Rule 5 | `kernel/src/hal/arch/aarch64/` 变更 | `qemu_aarch64` + `board_phytiumpi` + `board_rk3568` | +| Rule 5 | `kernel/src/hal/arch/x86_64/` 变更 | `qemu_x86_64` | +| Rule 6 | `axplat-x86-qemu-q35` crate 受影响 | `qemu_x86_64` | +| Rule 7 | `axfs` crate 受影响 | `qemu_aarch64` + `board_phytiumpi` + `board_rk3568` | +| Rule 8 | `driver` crate 受影响 — 飞腾派相关文件 | `board_phytiumpi` | +| Rule 8 | `driver` crate 受影响 — Rockchip 相关文件 | `board_rk3568` | +| Rule 8 | `driver` crate 受影响 — 通用驱动文件 | `board_phytiumpi` + `board_rk3568` | +| Rule 9 | `.github/workflows/` 下 QEMU 相关配置 | `qemu_aarch64` + `qemu_x86_64` | +| Rule 9 | `.github/workflows/` 下 Board/UBoot 相关配置 | `board_phytiumpi` + `board_rk3568` | +| Rule 10 | `configs/board/` 或 `configs/vms/` 下的配置文件 | 对应的特定目标 | + +### 跳过规则 + +以下文件变更不触发任何测试(`skip_all=true`): + +- `doc/` 目录下的文件 +- `*.md`、`*.txt`、`*.png`、`*.jpg`、`*.svg` 等 +- `LICENSE`、`.gitignore`、`.gitattributes` + +### 关于"直接修改"与"间接受影响"的区分 + +Rule 2 和 Rule 3 特意使用"直接修改的 crate"(`changed_crates`)而非"所有受影响的 crate"(`affected_crates`)进行判断。这是因为 `cargo metadata` 的依赖解析不区分条件编译依赖(`[target.'cfg(...)'.dependencies]`)。例如 `axruntime` 对 `axplat-x86-qemu-q35` 的依赖仅在 x86_64 目标下生效,但 `cargo metadata` 会无条件地将其包含在依赖图中。如果不区分,修改 x86 平台 crate 就会通过 `axruntime` 间接触发全量测试。 + +## 文件变更清单 + +| 文件 | 变更类型 | 说明 | +|------|----------|------| +| `xtask/src/affected.rs` | 新增 | 核心分析引擎(约 400 行) | +| `xtask/src/main.rs` | 修改 | 注册 `Affected` 子命令 | +| `.github/workflows/test-qemu.yml` | 修改 | 添加 `detect` job,动态构建测试矩阵 | +| `.github/workflows/test-board.yml` | 修改 | 添加 `detect` job,动态构建测试矩阵 | + +## CI 工作流变更 + +### 改动前 + +``` +push/PR → test-qemu job (固定 3 个矩阵项) → 全部在 self-hosted Runner 上执行 +push/PR → test-board job (固定 4 个矩阵项) → 全部在 self-hosted Runner 上执行 +``` + +### 改动后 + +``` +push/PR → detect job (ubuntu-latest, 轻量级) + │ + ├─ 分析影响范围 + ├─ 动态构建测试矩阵(仅包含受影响的目标) + │ + └──→ test job (self-hosted Runner) + 仅运行矩阵中的配置项 + 如果矩阵为空则整个 job 被跳过 +``` + +`detect` job 运行在 GitHub 提供的标准 `ubuntu-latest` Runner 上,不占用稀缺的硬件 Runner 资源。通过 `actions/cache` 缓存 xtask 的编译产物,后续运行接近零开销。 + +## 使用方法 + +### 本地使用 + +```bash +# 对比 main 分支,查看需要运行哪些测试 +cargo xtask affected --base origin/main + +# 对比上一个 commit +cargo xtask affected --base HEAD~1 + +# 对比某个特定 commit +cargo xtask affected --base abc1234 +``` + +输出示例: + +```json +{ + "skip_all": false, + "qemu_aarch64": true, + "qemu_x86_64": false, + "board_phytiumpi": false, + "board_rk3568": false, + "changed_crates": [ + "axvisor" + ], + "affected_crates": [ + "axvisor" + ] +} +``` + +同时 `stderr` 会输出详细的分析过程,便于调试: + +``` +[affected] changed files (1): + kernel/src/hal/arch/aarch64/api.rs +[affected] workspace crates: ["axvisor", "nop", "axconfig", ...] +[affected] reverse deps: + axconfig ← {"axruntime", "axvisor"} + axruntime ← {"axvisor"} + driver ← {"axvisor"} +[affected] directly changed crates: {"axvisor"} +[affected] all affected crates: {"axvisor"} +[affected] test scope: qemu_aarch64=true qemu_x86_64=false board_phytiumpi=false board_rk3568=false +``` + +### CI 中自动执行 + +无需手动操作。当 push 或创建 PR 时,CI 工作流会自动: + +1. 运行 `detect` job 分析影响范围 +2. 将分析结果写入 `$GITHUB_OUTPUT` +3. 根据结果动态构建测试矩阵 +4. 仅在受影响的硬件 Runner 上执行测试 + +## 验证结果 + +以下场景已在本地通过验证: + +| 场景 | 变更文件 | 结果 | +|------|----------|------| +| 只改文档 | `doc/shell.md` | `skip_all=true`,跳过所有测试 | +| 改 aarch64 HAL | `kernel/src/hal/arch/aarch64/api.rs` | QEMU aarch64 + 两块 ARM 开发板 | +| 改飞腾派驱动 | `modules/driver/src/blk/phytium.rs` | 仅飞腾派开发板 | +| 改 x86 平台 crate | `platform/x86-qemu-q35/src/lib.rs` | 仅 QEMU x86_64 | +| 改 axruntime | `modules/axruntime/src/lib.rs` | 全部测试(核心模块) | +| 改 kernel 通用代码 | `kernel/src/main.rs` | 全部测试 | +| 改 Rockchip 驱动 | `modules/driver/src/soc/rockchip/pm.rs` | 仅 RK3568 开发板 | + +## 扩展指南 + +### 添加新的开发板 + +当添加新的开发板支持时,需要: + +1. 在 `xtask/src/affected.rs` 的 `TestScope` 结构体中添加新的布尔字段 +2. 在 `determine_targets()` 中添加对应的规则 +3. 在 `run()` 中将新字段写入 `$GITHUB_OUTPUT` +4. 在 CI 工作流的 `Build board test matrix` 步骤中添加对应的矩阵项 + +### 添加新的 workspace crate + +无需额外操作。`cargo metadata` 会自动发现新的 workspace 成员及其依赖关系。如果新 crate 是平台特定的,需要在 `determine_targets()` 中添加对应的映射规则。 + +### 修改规则 + +所有映射规则集中在 `xtask/src/affected.rs` 的 `determine_targets()` 函数中,便于统一维护。 From b4721d5df4a63c743299683daa3646a873629c42 Mon Sep 17 00:00:00 2001 From: yoinspiration Date: Wed, 25 Feb 2026 01:34:40 +0800 Subject: [PATCH 5/5] ci: install libudev dependencies for detect jobs Install pkg-config and libudev-dev in workflow detect jobs so cargo xtask affected can build libudev-sys on ubuntu runners. Co-authored-by: Cursor --- .github/workflows/test-board.yml | 5 +++++ .github/workflows/test-qemu.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/test-board.yml b/.github/workflows/test-board.yml index 6258c041..70f3379b 100644 --- a/.github/workflows/test-board.yml +++ b/.github/workflows/test-board.yml @@ -20,6 +20,11 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Install system deps + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libudev-dev + - name: Cache cargo build uses: actions/cache@v4 with: diff --git a/.github/workflows/test-qemu.yml b/.github/workflows/test-qemu.yml index 3f2650af..86589478 100644 --- a/.github/workflows/test-qemu.yml +++ b/.github/workflows/test-qemu.yml @@ -20,6 +20,11 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Install system deps + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libudev-dev + - name: Cache cargo build uses: actions/cache@v4 with: