diff --git a/.github/workflows/check-ubuntu.yml b/.github/workflows/check-ubuntu.yml index 1e1e0a36c..182115af5 100644 --- a/.github/workflows/check-ubuntu.yml +++ b/.github/workflows/check-ubuntu.yml @@ -103,6 +103,10 @@ jobs: - name: Test id: tests run: uv run --no-project python ci/test_cache_status.py -- soldr cargo test --workspace + - name: Test optional SDK library linkage with native GCC LTO + run: | + soldr cargo test -p fbuild-build-engine --lib optional_framework_archive_preserves_only_referenced_initializers -- --ignored + soldr cargo test -p fbuild-build-arm --lib optional_library_rescans_earlier_dependency_archive -- --ignored python-facade-tests: # FastLED/fbuild#1485 §5.2/§6: the embedded-CPython AT-P integration diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 5a2aff456..c4d7871bd 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -118,3 +118,52 @@ jobs: throw "Warm check did not reuse the verified source launcher" } # The hosted runner discards RUNNER_TEMP after the job. + + # Pioarduino 55.03.35 pins esptool 5.1.0, whose release has no + # Windows standalone binary. Verify the real source-package route. + - name: Install and recheck ESP32-C6 source tools + id: modern-esptool + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $probe = Join-Path $env:RUNNER_TEMP ("fbuild-modern-esptool-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $probe | Out-Null + $env:FBUILD_CACHE_DIR = Join-Path $probe "cache" + [Environment]::SetEnvironmentVariable("FBUILD_ESPTOOL_PATH", $null, "Process") + @" + [env:esp32c6-idf5] + platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.35/platform-espressif32.zip + board = esp32-c6-devkitc-1 + framework = arduino + "@ | Set-Content -LiteralPath (Join-Path $probe "platformio.ini") -Encoding utf8 + + $coldJson = & soldr cargo run -p fbuild-cli --bin fbuild -- install "$probe" -e esp32c6-idf5 --json + if ($LASTEXITCODE -ne 0) { throw "Cold ESP32-C6 install failed ($LASTEXITCODE)" } + $coldJson | Write-Output + $cold = ($coldJson -join "`n") | ConvertFrom-Json + $coldTools = @($cold.environments.packages | Where-Object name -eq "tool-esptoolpy") + if ($coldTools.Count -ne 1) { throw "Expected exactly one esptool package" } + $coldTool = $coldTools[0] + if ($coldTool.version -ne "5.1.0" -or $coldTool.status -ne "fetched" -or + $coldTool.url -ne "https://github.com/pioarduino/esptool/releases/download/v5.1.0/esptool.zip") { + throw "Expected a cold install of the pinned esptool 5.1.0 source archive" + } + if (-not (Test-Path -LiteralPath $coldTool.install_path -PathType Leaf)) { + throw "Installed esptool launcher is missing" + } + if ($coldTool.install_path -notmatch '[\\/]\.fbuild-tools[\\/]esptool[\\/]Scripts[\\/]esptool\.exe$') { + throw "Expected the real uv-managed Windows esptool launcher" + } + + $warmJson = & soldr cargo run -p fbuild-cli --bin fbuild -- install "$probe" -e esp32c6-idf5 --check --json + if ($LASTEXITCODE -ne 0) { throw "Warm ESP32-C6 check failed ($LASTEXITCODE)" } + $warmJson | Write-Output + $warm = ($warmJson -join "`n") | ConvertFrom-Json + $warmTools = @($warm.environments.packages | Where-Object name -eq "tool-esptoolpy") + if ($warmTools.Count -ne 1) { throw "Expected exactly one cached esptool package" } + $warmTool = $warmTools[0] + if ($warmTool.version -ne "5.1.0" -or $warmTool.status -ne "present" -or + $warmTool.url -ne $coldTool.url -or $warmTool.install_path -ne $coldTool.install_path) { + throw "Warm check did not reuse the verified source launcher" + } + # The hosted runner discards RUNNER_TEMP after the job. diff --git a/Cargo.lock b/Cargo.lock index 238cdd013..56c36b601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -988,7 +988,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fbuild-bench-fastled-examples" -version = "2.5.40" +version = "2.5.41" dependencies = [ "fbuild-core", "fbuild-library-select", @@ -1002,7 +1002,7 @@ dependencies = [ [[package]] name = "fbuild-build" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "blake3", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "fbuild-build-arm" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "blake3", @@ -1073,7 +1073,7 @@ dependencies = [ [[package]] name = "fbuild-build-engine" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "blake3", @@ -1107,7 +1107,7 @@ dependencies = [ [[package]] name = "fbuild-build-esp" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "blake3", @@ -1142,7 +1142,7 @@ dependencies = [ [[package]] name = "fbuild-build-mcu" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "blake3", @@ -1176,7 +1176,7 @@ dependencies = [ [[package]] name = "fbuild-cli" -version = "2.5.40" +version = "2.5.41" dependencies = [ "blake3", "clap", @@ -1208,7 +1208,7 @@ dependencies = [ [[package]] name = "fbuild-config" -version = "2.5.40" +version = "2.5.41" dependencies = [ "fbuild-core", "fbuild-paths", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "fbuild-core" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "fs2", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "fbuild-daemon" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "axum 0.7.9", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "fbuild-deploy" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "espflash", @@ -1319,7 +1319,7 @@ dependencies = [ [[package]] name = "fbuild-header-scan" -version = "2.5.40" +version = "2.5.41" dependencies = [ "criterion", "fbuild-paths", @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "fbuild-library" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "axum 0.7.9", @@ -1362,7 +1362,7 @@ dependencies = [ [[package]] name = "fbuild-library-select" -version = "2.5.40" +version = "2.5.41" dependencies = [ "bincode", "blake3", @@ -1383,7 +1383,7 @@ dependencies = [ [[package]] name = "fbuild-packages" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "axum 0.7.9", @@ -1415,7 +1415,7 @@ dependencies = [ [[package]] name = "fbuild-packages-fetch" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "axum 0.7.9", @@ -1444,7 +1444,7 @@ dependencies = [ [[package]] name = "fbuild-paths" -version = "2.5.40" +version = "2.5.41" dependencies = [ "blake3", "fbuild-core", @@ -1456,7 +1456,7 @@ dependencies = [ [[package]] name = "fbuild-python" -version = "2.5.40" +version = "2.5.41" dependencies = [ "base64", "fbuild-core", @@ -1480,7 +1480,7 @@ dependencies = [ [[package]] name = "fbuild-serial" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "base64", @@ -1503,7 +1503,7 @@ dependencies = [ [[package]] name = "fbuild-test-support" -version = "2.5.40" +version = "2.5.41" dependencies = [ "fbuild-config", "fbuild-core", @@ -1523,7 +1523,7 @@ dependencies = [ [[package]] name = "fbuild-toolchain" -version = "2.5.40" +version = "2.5.41" dependencies = [ "async-trait", "axum 0.7.9", diff --git a/Cargo.toml b/Cargo.toml index 66d15837e..3c3c270ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,7 +93,7 @@ targets = [ ] [workspace.package] -version = "2.5.40" +version = "2.5.41" edition = "2021" rust-version = "1.95.0" license = "AGPL-3.0-only" diff --git a/ci/test_fractional_workflows.py b/ci/test_fractional_workflows.py index f2f777cbf..a80066e96 100644 --- a/ci/test_fractional_workflows.py +++ b/ci/test_fractional_workflows.py @@ -13,6 +13,31 @@ class FractionalWorkflowTests(unittest.TestCase): + def test_windows_checks_real_modern_esptool_source_install(self): + job = self.load("check-windows.yml")["jobs"]["check"] + smoke = next(step for step in job["steps"] if step.get("id") == "modern-esptool") + self.assertEqual("windows-latest", job["runs-on"]) + self.assertEqual("pwsh", smoke["shell"]) + self.assertNotIn("if", smoke) + self.assertFalse(smoke.get("continue-on-error", False)) + script = smoke["run"] + for contract in ( + "$env:RUNNER_TEMP", "[guid]::NewGuid()", "$env:FBUILD_CACHE_DIR", + 'SetEnvironmentVariable("FBUILD_ESPTOOL_PATH", $null, "Process")', + "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.35/platform-espressif32.zip", + "board = esp32-c6-devkitc-1", "framework = arduino", + "soldr cargo run -p fbuild-cli --bin fbuild -- install", + "--check --json", "$LASTEXITCODE -ne 0", "ConvertFrom-Json", + 'name -eq "tool-esptoolpy"', 'version -ne "5.1.0"', + 'status -ne "fetched"', 'status -ne "present"', + "https://github.com/pioarduino/esptool/releases/download/v5.1.0/esptool.zip", + 'Test-Path -LiteralPath $coldTool.install_path -PathType Leaf', + '$warmTool.install_path -ne $coldTool.install_path', + ): + self.assertIn(contract, script) + self.assertEqual(2, script.count("soldr cargo run -p fbuild-cli --bin fbuild -- install")) + self.assertEqual(2, script.count("$LASTEXITCODE -ne 0")) + def test_macos_lipo_places_input_before_variadic_architectures(self): job = self.load("release-auto.yml")["jobs"]["macos-release-smoke"] script = next(step["run"] for step in job["steps"] if "lipo " in step.get("run", "")) diff --git a/crates/fbuild-build-arm/src/rp2040/orchestrator.rs b/crates/fbuild-build-arm/src/rp2040/orchestrator.rs index 9ca6df5f3..9bcead3e5 100644 --- a/crates/fbuild-build-arm/src/rp2040/orchestrator.rs +++ b/crates/fbuild-build-arm/src/rp2040/orchestrator.rs @@ -41,6 +41,9 @@ pub struct Rp2040Orchestrator; #[derive(Debug, Serialize)] struct Rp2040FingerprintMetadata { version: u32, + // Invalidate firmware produced before SDK platform.txt defaults were applied. + platform_define_policy: &'static str, + framework_library_linkage: &'static str, env_name: String, profile: String, board_name: String, @@ -201,6 +204,8 @@ impl BuildOrchestrator for Rp2040Orchestrator { let metadata_hash = stable_hash_with_build_config( &Rp2040FingerprintMetadata { version: BUILD_FINGERPRINT_VERSION, + platform_define_policy: "platform-sdfatdefaults-v1", + framework_library_linkage: "local-and-framework-archive-v2", env_name: params.env_name.clone(), profile: profile_label(params.profile).to_string(), board_name: ctx.board.name.clone(), @@ -294,7 +299,7 @@ impl BuildOrchestrator for Rp2040Orchestrator { } add_rp_manifest_defines(&framework_dir, &ctx.board.mcu, &mut defines); apply_rp_board_props(&board_props, &framework_dir, &mut defines); - apply_rp_platform_net_defines(&framework_dir, &board_props, &mut defines)?; + apply_rp_platform_defines(&framework_dir, &board_props, &mut defines)?; // Arduino-Pico ships framework libraries (WiFi, SPI, Wire, ...) under // `libraries/`. Mirror PlatformIO's LDF so their headers are visible @@ -352,7 +357,9 @@ impl BuildOrchestrator for Rp2040Orchestrator { "RP2040 framework library sources added: {}", framework_library_sources.len() ); - sources.core_sources.extend(framework_library_sources); + sources + .core_sources + .extend(framework_library_sources.iter().cloned()); } // Use the resolved core_dir/variant_dir instead of board.get_include_paths(): // RP2040 board metadata reports `core = earlephilhower`, while the actual @@ -521,7 +528,7 @@ impl BuildOrchestrator for Rp2040Orchestrator { // 9. Run shared sequential build pipeline let board_mcu = ctx.board.mcu.clone(); - let mut build_result = pipeline::run_sequential_build_with_libs( + let mut build_result = pipeline::run_sequential_build_with_optional_framework_libs( &compiler, &linker, ctx, @@ -530,6 +537,7 @@ impl BuildOrchestrator for Rp2040Orchestrator { &support_link_inputs, lib_deps_plan, Some(&lib_env), + Some(&framework_library_sources), TargetArchitecture::Arm, "RP2040", start, @@ -728,22 +736,33 @@ fn apply_rp_board_props( } } -/// Apply Arduino-Pico's network compiler definitions after expanding values -/// selected by the board's menu properties. These flags are declared by the -/// framework rather than owned by an individual board, so they must be read -/// from its platform metadata instead of being duplicated here. -fn apply_rp_platform_net_defines( +/// Apply Arduino-Pico's framework compiler definitions, with selected board +/// properties overriding platform defaults. SdFat's public-copy setting lives +/// in platform.txt, not boards.txt, and is required to compile bundled SDFS. +fn apply_rp_platform_defines( framework_dir: &Path, board_props: &Option>, defines: &mut HashMap, ) -> Result<()> { let platform_txt = framework_dir.join("platform.txt"); let content = std::fs::read_to_string(platform_txt)?; - let Some(flags) = content.lines().find_map(|line| { - line.trim() - .strip_prefix("compiler.netdefines=") - .map(str::trim) - }) else { + let property = |name: &str| { + content.lines().find_map(|line| { + let (key, value) = line.trim().split_once('=')?; + (key.trim() == name).then(|| value.trim()) + }) + }; + let sdfat_flags = board_props + .as_ref() + .and_then(|props| props.get("sdfatdefines")) + .map(String::as_str) + .or_else(|| property("build.sdfatdefines")); + if let Some(flags) = sdfat_flags { + let expanded = flags.replace("{runtime.platform.path}", &framework_dir.to_string_lossy()); + apply_define_flags(&fbuild_core::shell_split::split(&expanded), defines); + } + + let Some(flags) = property("compiler.netdefines") else { return Ok(()); }; @@ -1038,6 +1057,96 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn platform_default_policy_invalidates_legacy_firmware_cache() { + assert_policy_invalidates_legacy_firmware_cache("platform_define_policy", None); + } + + #[test] + fn framework_archive_policy_invalidates_legacy_firmware_cache() { + assert_policy_invalidates_legacy_firmware_cache("framework_library_linkage", None); + } + + #[test] + fn local_archive_policy_invalidates_sdk_only_firmware_cache() { + assert_policy_invalidates_legacy_firmware_cache( + "framework_library_linkage", + Some("archive"), + ); + } + + fn assert_policy_invalidates_legacy_firmware_cache(policy: &str, previous_value: Option<&str>) { + use crate::build_fingerprint::{ + PersistedBuildFingerprint, fast_path_check, hash_watch_set_stamps, save_json, + stable_hash_json, + }; + + let metadata = Rp2040FingerprintMetadata { + version: BUILD_FINGERPRINT_VERSION, + platform_define_policy: "platform-sdfatdefaults-v1", + framework_library_linkage: "local-and-framework-archive-v2", + env_name: "pico".into(), + profile: "release".into(), + board_name: "Pico".into(), + board_mcu: "rp2040".into(), + board_define: "ARDUINO_RASPBERRY_PI_PICO".into(), + board_core: "rp2040".into(), + board_variant: "rpipico".into(), + board_f_cpu: "133000000L".into(), + board_extra_flags: None, + board_upload_protocol: None, + board_menu_overrides: BTreeMap::new(), + platform: "raspberrypi".into(), + max_flash: None, + max_ram: None, + eh_frame_policy: "strip", + }; + let current = serde_json::to_value(&metadata).unwrap(); + let mut legacy = current.clone(); + // Successful builds before the policy change did not encode this field. + legacy.as_object_mut().unwrap().remove(policy); + if let Some(value) = previous_value { + legacy + .as_object_mut() + .unwrap() + .insert(policy.into(), value.into()); + } + let current_hash = stable_hash_json(¤t).unwrap(); + let legacy_hash = stable_hash_json(&legacy).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let source_dir = tmp.path().join("src"); + std::fs::create_dir(&source_dir).unwrap(); + std::fs::write(source_dir.join("main.cpp"), "void setup() {}\n").unwrap(); + let firmware = tmp.path().join("firmware.elf"); + std::fs::write(&firmware, "previous successful firmware").unwrap(); + let mut contract = FastPathContract::new(tmp.path()); + contract.add_watch_root("project", &source_dir); + contract.add_required_artifacts([firmware]); + let mut persisted = PersistedBuildFingerprint { + version: BUILD_FINGERPRINT_VERSION, + metadata_hash: legacy_hash, + file_set_hash: Some(hash_watch_set_stamps(contract.watches()).unwrap()), + size_info: None, + }; + save_json(contract.fingerprint_path(), &persisted).unwrap(); + let inputs = FastPathCheckInputs { + metadata_hash: ¤t_hash, + extra_artifact_ok: None, + watch_set_cache: None, + compiler_cache: None, + }; + assert!( + fast_path_check(&contract, &inputs).unwrap().is_none(), + "firmware built without current {policy} must be rebuilt" + ); + persisted.metadata_hash = current_hash.clone(); + save_json(contract.fingerprint_path(), &persisted).unwrap(); + assert!( + fast_path_check(&contract, &inputs).unwrap().is_some(), + "unchanged firmware built with current {policy} remains reusable" + ); + } + #[test] fn rp2040_consumes_resolved_framework_toolchain_and_picotool() { let tmp = tempfile::tempdir().unwrap(); @@ -1186,6 +1295,127 @@ mod tests { ); } + #[test] + fn test_rp_platform_sdfat_defaults_without_network_flags() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("platform.txt"), + "build.sdfatdefines=-DFILE_COPY_CONSTRUCTOR_SELECT=FILE_COPY_CONSTRUCTOR_PUBLIC -DUSE_UTF8_LONG_NAMES=1 -DDISABLE_FS_H_WARNING=1\n", + ) + .unwrap(); + let mut defines = HashMap::new(); + apply_rp_platform_defines(tmp.path(), &None, &mut defines).unwrap(); + assert_eq!( + defines + .get("FILE_COPY_CONSTRUCTOR_SELECT") + .map(String::as_str), + Some("FILE_COPY_CONSTRUCTOR_PUBLIC") + ); + assert_eq!( + defines.get("USE_UTF8_LONG_NAMES").map(String::as_str), + Some("1") + ); + assert_eq!( + defines.get("DISABLE_FS_H_WARNING").map(String::as_str), + Some("1") + ); + } + + #[tokio::test] + #[ignore = "requires Arduino-Pico SDK and GCC in FBUILD_TEST_PICO_FRAMEWORK and FBUILD_TEST_PICO_CXX"] + async fn test_rp_platform_sdfat_compiles_real_sdk_sdfs() { + let framework = fbuild_core::path::NormalizedPath::from( + std::env::var("FBUILD_TEST_PICO_FRAMEWORK").unwrap(), + ) + .into_path_buf(); + let cxx = + fbuild_core::path::NormalizedPath::from(std::env::var("FBUILD_TEST_PICO_CXX").unwrap()) + .into_path_buf(); + for board_id in ["rpipico", "rpipico2"] { + let board = + fbuild_config::BoardConfig::from_board_id(board_id, &HashMap::new()).unwrap(); + let props = crate::arduino_props::load_board_props_with_default_menus( + &framework.join("boards.txt"), + board_id, + ); + let mcu = super::super::mcu_config::get_rp2040_config_for_mcu(&board.mcu).unwrap(); + let mut defines = board.get_defines(); + defines.extend(mcu.defines_map()); + add_rp_manifest_defines(&framework, &board.mcu, &mut defines); + apply_rp_board_props(&props, &framework, &mut defines); + apply_rp_platform_defines(&framework, &props, &mut defines).unwrap(); + let mut includes = vec![ + framework.join("cores/rp2040"), + framework.join("variants").join(&board.variant), + framework.join("include"), + ]; + add_rp_manifest_includes(&framework, &board.mcu, &mut includes); + add_rp_family_includes(&framework.join("include"), &board.mcu, &mut includes); + add_rp_board_includes(&props, &framework, &mut includes); + for library in ["SDFS", "SdFat", "SPI"] { + includes.push(framework.join("libraries").join(library).join("src")); + } + let compiler = ArmCompiler::new( + cxx.clone(), + cxx.clone(), + &board.mcu, + &board.f_cpu, + defines, + includes, + mcu, + fbuild_core::BuildProfile::Quick, + false, + ); + let mut command = vec![cxx.to_string_lossy().into_owned()]; + command.extend( + compiler + .cpp_flags() + .into_iter() + .filter(|flag| flag != "-MMD"), + ); + command.push("-fsyntax-only".into()); + command.push( + framework + .join("libraries/SDFS/src/SDFS.cpp") + .to_string_lossy() + .into_owned(), + ); + let args: Vec<&str> = command.iter().map(String::as_str).collect(); + let output = fbuild_core::subprocess::run_command( + &args, + None, + None, + Some(std::time::Duration::from_secs(60)), + ) + .await + .unwrap(); + assert!(output.success(), "{board_id}: {}", output.stderr); + } + } + + #[test] + fn test_rp_platform_sdfat_board_override_replaces_default_flags() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("platform.txt"), + "build.sdfatdefines=-DFILE_COPY_CONSTRUCTOR_SELECT=FILE_COPY_CONSTRUCTOR_PUBLIC -DUSE_UTF8_LONG_NAMES=1\n", + ) + .unwrap(); + let board_props = Some(HashMap::from([( + "sdfatdefines".to_string(), + "-DFILE_COPY_CONSTRUCTOR_SELECT=FILE_COPY_CONSTRUCTOR_PRIVATE".to_string(), + )])); + let mut defines = HashMap::new(); + apply_rp_platform_defines(tmp.path(), &board_props, &mut defines).unwrap(); + assert_eq!( + defines + .get("FILE_COPY_CONSTRUCTOR_SELECT") + .map(String::as_str), + Some("FILE_COPY_CONSTRUCTOR_PRIVATE") + ); + assert!(!defines.contains_key("USE_UTF8_LONG_NAMES")); + } + #[test] fn test_apply_rp_platform_net_defines_expands_board_wifi_flags() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1200,7 +1430,7 @@ mod tests { )])); let mut defines = HashMap::new(); - apply_rp_platform_net_defines(tmp.path(), &board_props, &mut defines).unwrap(); + apply_rp_platform_defines(tmp.path(), &board_props, &mut defines).unwrap(); assert_eq!(defines.get("LWIP_IPV6").map(String::as_str), Some("0")); assert_eq!(defines.get("LWIP_IPV4").map(String::as_str), Some("1")); diff --git a/crates/fbuild-build-arm/src/teensy/orchestrator.rs b/crates/fbuild-build-arm/src/teensy/orchestrator.rs index ef95d9f41..688875277 100644 --- a/crates/fbuild-build-arm/src/teensy/orchestrator.rs +++ b/crates/fbuild-build-arm/src/teensy/orchestrator.rs @@ -53,6 +53,7 @@ struct TeensyFingerprintMetadata { max_flash: Option, max_ram: Option, eh_frame_policy: &'static str, + framework_library_linkage: &'static str, } fn profile_label(profile: fbuild_core::BuildProfile) -> &'static str { @@ -187,6 +188,7 @@ impl BuildOrchestrator for TeensyOrchestrator { crate::eh_frame_policy::EhFramePolicy::Strip => "strip", crate::eh_frame_policy::EhFramePolicy::Preserve => "preserve", }, + framework_library_linkage: "local-and-framework-archive-v2", }, &ctx, )?; @@ -290,7 +292,9 @@ impl BuildOrchestrator for TeensyOrchestrator { "Teensy framework library sources added: {}", framework_library_sources.len() ); - sources.core_sources.extend(framework_library_sources); + sources + .core_sources + .extend(framework_library_sources.iter().cloned()); } tracing::info!( @@ -413,7 +417,7 @@ impl BuildOrchestrator for TeensyOrchestrator { }; // 9. Run shared sequential build pipeline - let build_result = pipeline::run_sequential_build_with_libs( + let build_result = pipeline::run_sequential_build_with_optional_framework_libs( &compiler, &linker, ctx, @@ -422,6 +426,7 @@ impl BuildOrchestrator for TeensyOrchestrator { &[], lib_deps_plan, Some(&lib_env), + Some(&framework_library_sources), TargetArchitecture::Arm, "Teensy", start, diff --git a/crates/fbuild-build-arm/src/teensy/teensy_linker.rs b/crates/fbuild-build-arm/src/teensy/teensy_linker.rs index d890b8533..6c92e18f4 100644 --- a/crates/fbuild-build-arm/src/teensy/teensy_linker.rs +++ b/crates/fbuild-build-arm/src/teensy/teensy_linker.rs @@ -98,6 +98,9 @@ impl TeensyLinker { args.push(obj.to_string_lossy().to_string()); } + // Rescan ordinary archives: a local/SDK member can reference an earlier + // lib_deps/project archive. This does not retain unreferenced members. + args.push("-Wl,--start-group".into()); // Core objects passed directly (not archived) for LTO compatibility for archive in archives { args.push(archive.to_string_lossy().to_string()); @@ -115,6 +118,7 @@ impl TeensyLinker { args.push(format!("-l{}", lib)); } args.extend(extra.libs.iter().cloned()); + args.push("-Wl,--end-group".into()); args } @@ -266,6 +270,93 @@ mod tests { use super::*; use crate::teensy::mcu_config::get_teensy_config; + async fn run_native(args: &[String]) { + let borrowed: Vec<_> = args.iter().map(String::as_str).collect(); + let result = run_command( + &borrowed, + None, + None, + Some(std::time::Duration::from_secs(30)), + ) + .await + .unwrap(); + assert!(result.success(), "{args:?}: {}", result.stderr); + } + + #[tokio::test] + #[ignore = "requires native GCC and GNU ar; explicitly run in Check Ubuntu CI"] + async fn optional_library_rescans_earlier_dependency_archive() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let fixtures = [ + ( + "main", + "int state; extern int optional(void); int main(void) { return state != 0 || optional() != 7; }", + ), + ( + "optional", + "extern int dependency(void); int optional(void) { return dependency(); }", + ), + ("dependency", "int dependency(void) { return 7; }"), + ( + "unused", + "extern int state; __attribute__((constructor)) void unused(void) { state = 1; }", + ), + ]; + let mut objects = Vec::new(); + for (name, body) in fixtures { + let source = root.join(format!("{name}.c")); + let object = root.join(format!("{name}.o")); + std::fs::write(&source, body).unwrap(); + run_native(&[ + "gcc".into(), + "-ffunction-sections".into(), + "-c".into(), + source.display().to_string(), + "-o".into(), + object.display().to_string(), + ]) + .await; + objects.push(object); + } + let earlier = root.join("libDeps.a"); + let optional = root.join("libOptional.a"); + crate::linker::LinkerBase::archive(Path::new("ar"), &objects[2..], &earlier, "ar") + .await + .unwrap(); + crate::linker::LinkerBase::archive(Path::new("ar"), &objects[1..2], &optional, "ar") + .await + .unwrap(); + // Use the actual Teensy command builder with only its target-specific + // flags removed so the host GNU linker can execute this fixture. + let mut config = get_teensy_config().unwrap(); + config.linker_flags = vec!["-Wl,--gc-sections".into()]; + config.linker_libs.clear(); + config.profiles.clear(); + let linker = TeensyLinker::new( + "gcc".into(), + "ar".into(), + PathBuf::new(), + PathBuf::new(), + LinkerScripts::new(), + config, + BuildProfile::Release, + None, + None, + None, + false, + ); + let executable = root.join("firmware"); + run_native(&linker.build_link_args( + &objects[..1], + &[earlier, optional], + &executable, + &LinkExtraArgs::default(), + )) + .await; + run_native(&[executable.display().to_string()]).await; + } + /// The link must not run in the daemon's inherited cwd: collect2 / /// lto-wrapper write scratch files relative to it, which put a stray /// 0-byte `-r` in the user's repo root on every clean build. diff --git a/crates/fbuild-build-engine/src/pipeline/library_shared_gate_tests.rs b/crates/fbuild-build-engine/src/pipeline/library_shared_gate_tests.rs index 718b47203..75b18fe6e 100644 --- a/crates/fbuild-build-engine/src/pipeline/library_shared_gate_tests.rs +++ b/crates/fbuild-build-engine/src/pipeline/library_shared_gate_tests.rs @@ -19,12 +19,13 @@ use crate::parallel::compile_sources_parallel_shared; use std::path::Path; /// Fake in-process "core" compiler: records a (label, phase, ns) event to -/// the shared log for every compile, sleeping long enough that at least -/// one lib_deps archive compile is guaranteed to overlap it. +/// the shared log for every compile, retaining its gate permit until the +/// library phase finishes, regardless of subprocess startup latency. struct FakeCoreCompiler { gcc: NormalizedPath, log: Arc>>, - sleep_ms: u64, + started: Arc, + libraries_done: Arc, } fn now_ns() -> u128 { @@ -48,7 +49,13 @@ impl Compiler for FakeCoreCompiler { .lock() .unwrap() .push(("core".to_string(), "start".to_string(), now_ns())); - tokio::time::sleep(std::time::Duration::from_millis(self.sleep_ms)).await; + self.started.notify_one(); + tokio::time::timeout( + std::time::Duration::from_secs(30), + self.libraries_done.notified(), + ) + .await + .expect("library phase must finish while core retains one shared gate permit"); self.log .lock() .unwrap() @@ -227,14 +234,13 @@ async fn lib_deps_and_core_share_one_job_gate() { let core_log: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); + let core_started = Arc::new(tokio::sync::Notify::new()); + let libraries_done = Arc::new(tokio::sync::Notify::new()); let core_compiler = FakeCoreCompiler { gcc: NormalizedPath::new(&gcc), log: core_log.clone(), - // Long enough that the two ~0.1s library compiles (which - // must serialize behind each other once the gate's second - // slot is claimed by this core compile) are guaranteed to - // land inside this compile's [start, end) window. - sleep_ms: 300, + started: core_started.clone(), + libraries_done: libraries_done.clone(), }; let core_source = project.join("core.cpp"); std::fs::write(&core_source, "int g();\n").unwrap(); @@ -253,7 +259,15 @@ async fn lib_deps_and_core_share_one_job_gate() { None, None, ), - lib_deps.compile(&gate), + async { + core_started.notified().await; + // Exercise startup slower than the old 300 ms core sleep. + // Correctness depends on completion signaling, not this delay. + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + let result = lib_deps.compile(&gate).await; + libraries_done.notify_one(); + result + }, ); let core_result = core_result.unwrap(); @@ -317,7 +331,7 @@ async fn lib_deps_and_core_share_one_job_gate() { // The two lib compiles must not run concurrently with each // other AND the core compile at once (only one gate, size 2: - // core takes one slot for its whole 300ms, so the two lib + // core takes one slot until both libraries finish, so the two lib // compiles must themselves serialize behind the gate's other // slot). assert!( diff --git a/crates/fbuild-build-engine/src/pipeline/mod.rs b/crates/fbuild-build-engine/src/pipeline/mod.rs index a9ca78dce..3714f358e 100644 --- a/crates/fbuild-build-engine/src/pipeline/mod.rs +++ b/crates/fbuild-build-engine/src/pipeline/mod.rs @@ -27,4 +27,6 @@ pub use library::{ }; pub use link::{assemble_build_result, handle_link_result}; pub use project_discovery::{discover_project_includes, is_platform_project, is_project_a_library}; -pub use sequential::run_sequential_build_with_libs; +pub use sequential::{ + run_sequential_build_with_libs, run_sequential_build_with_optional_framework_libs, +}; diff --git a/crates/fbuild-build-engine/src/pipeline/sequential.rs b/crates/fbuild-build-engine/src/pipeline/sequential.rs index 810871c0b..c27bffca4 100644 --- a/crates/fbuild-build-engine/src/pipeline/sequential.rs +++ b/crates/fbuild-build-engine/src/pipeline/sequential.rs @@ -32,12 +32,48 @@ use super::link::{assemble_build_result, handle_link_result}; /// with the rest of the build. See [`compile_project_as_library`] and /// ISSUES.md Issue 1. #[allow(clippy::too_many_arguments)] +pub async fn run_sequential_build_with_libs( + compiler: &(dyn Compiler + Send + Sync), + linker: &(dyn crate::linker::Linker + Send + Sync), + ctx: BuildContext, + params: &BuildParams, + sources: &SourceCollection, + extra_link_inputs: &[PathBuf], + lib_deps: super::library::LibDeps, + lib_env: Option<&LibraryBuildEnv<'_>>, + arch: TargetArchitecture, + platform_label: &str, + start: Instant, +) -> Result { + run_sequential_build_with_optional_framework_libs( + compiler, + linker, + ctx, + params, + sources, + extra_link_inputs, + lib_deps, + lib_env, + None, + arch, + platform_label, + start, + ) + .await +} + +/// With `Some`, archive selected local and framework library objects together +/// so unused local code cannot extract SDK startup constructors. With `None`, +/// retain the legacy direct-object linkage. The framework source slice must be +/// a subset of the core/variant sources; `Some(&[])` still archives local libs. +/// The library environment supplies the platform's LTO-aware archiver. +#[allow(clippy::too_many_arguments)] #[expect( clippy::cognitive_complexity, clippy::too_many_lines, reason = "baseline, zackees/ci.yml#229" )] -pub async fn run_sequential_build_with_libs( +pub async fn run_sequential_build_with_optional_framework_libs( compiler: &(dyn Compiler + Send + Sync), linker: &(dyn crate::linker::Linker + Send + Sync), mut ctx: BuildContext, @@ -46,6 +82,7 @@ pub async fn run_sequential_build_with_libs( extra_link_inputs: &[PathBuf], lib_deps: super::library::LibDeps, lib_env: Option<&LibraryBuildEnv<'_>>, + optional_framework_sources: Option<&[PathBuf]>, arch: TargetArchitecture, platform_label: &str, start: Instant, @@ -254,7 +291,7 @@ pub async fn run_sequential_build_with_libs( &build_log_mutex, )); - // Compile local libraries (lib/* — loose objects, LTO-safe; per-lib parallel) + // Compile local libraries normally; opted-in platforms archive them below. let declared_lib_deps = ctx .config .get_lib_deps(¶ms.env_name) @@ -355,6 +392,13 @@ pub async fn run_sequential_build_with_libs( // Link crate::build_output::log_linking(&mut ctx.build_log, "Linking firmware.elf"); + let optional_objects: Option> = optional_framework_sources.map(|sources| { + sources + .iter() + .map(|source| crate::compiler::CompilerBase::object_path(source, &ctx.core_build_dir)) + .chain(library_objects.iter().cloned()) + .collect() + }); core_objects.extend(library_objects); core_objects.extend(extra_link_inputs.iter().cloned()); core_objects.extend(lib_deps_archives); @@ -362,10 +406,25 @@ pub async fn run_sequential_build_with_libs( // GCC accepts .a in the same positional slot as .o files. core_objects.push(archive); } + if let Some(optional_objects) = optional_objects.filter(|objects| !objects.is_empty()) { + let env = lib_env.ok_or_else(|| { + fbuild_core::FbuildError::BuildFailed( + "optional framework libraries require an LTO-aware library build environment" + .into(), + ) + })?; + core_objects = archive_optional_library_objects( + core_objects, + &optional_objects, + &ctx.build_dir, + env.ar_path, + ) + .await?; + } let link_result = { // FastLED/fbuild#1465: `link` covers `Linker::link_all` end to end - // (link + objcopy + size). No separate archive step exists: core - // objects are passed to the linker directly for LTO compatibility. + // (link + objcopy + size). Mandatory core objects remain direct; + // the optional library archive has already been prepared above. let _g = perf.phase("link"); crate::linker::Linker::link_all( linker, @@ -436,6 +495,38 @@ pub async fn run_sequential_build_with_libs( )) } +/// Preserve mandatory core startup objects, and place local and SDK objects in +/// one archive after every consumer. GNU archive extraction resolves dependencies +/// among its members without imposing the scanner's library discovery order. +async fn archive_optional_library_objects( + inputs: Vec, + optional_objects: &[PathBuf], + build_dir: &Path, + ar_path: &Path, +) -> Result> { + let optional: std::collections::HashSet<&PathBuf> = optional_objects.iter().collect(); + let (objects, mut inputs): (Vec<_>, Vec<_>) = inputs + .into_iter() + .partition(|input| optional.contains(input)); + if objects.len() != optional.len() { + return Err(fbuild_core::FbuildError::BuildFailed( + "optional library object missing from compiled link inputs".into(), + )); + } + if !objects.is_empty() { + let archive = build_dir.join("libOptionalLibraries.a"); + crate::linker::LinkerBase::archive_if_stale( + ar_path, + &objects, + &archive, + "optional library archiver", + ) + .await?; + inputs.push(archive); + } + Ok(inputs) +} + /// Await `future`, returning its value with the wall time it took. async fn timed( future: impl std::future::Future>, @@ -461,3 +552,114 @@ fn local_lib_dir_names(project_dir: &Path) -> std::collections::HashSet } names } + +#[cfg(test)] +mod optional_framework_tests { + use super::*; + use crate::compiler::CompilerBase; + use fbuild_core::subprocess::run_command; + + async fn run(args: &[String]) -> String { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let result = run_command(&args, None, None, Some(std::time::Duration::from_secs(30))) + .await + .unwrap(); + assert!(result.success(), "{args:?}: {}", result.stderr); + result.stdout + } + + async fn gcc_archiver(cc: &str) -> PathBuf { + // Compiler wrappers need not expose gcc-ar on PATH. Ask GCC for its + // actual frontend, then use the companion archiver in that toolchain. + let frontend = run(&[cc.into(), "-print-prog-name=cc1".into()]).await; + let frontend = Path::new(frontend.trim()); + if frontend.is_absolute() { + let name = fbuild_core::platform::executable::native_name("gcc-ar"); + for ancestor in frontend.ancestors().skip(1) { + let candidate = ancestor.join("bin").join(&name); + if candidate.is_file() { + return candidate; + } + } + } + let reported = run(&[cc.into(), "-print-prog-name=gcc-ar".into()]).await; + PathBuf::from(reported.trim()) + } + + #[tokio::test] + #[ignore = "requires native GCC and matching gcc-ar; explicitly run in Check Ubuntu CI"] + async fn optional_framework_archive_preserves_only_referenced_initializers() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let cc = std::env::var("FBUILD_TEST_CC").unwrap_or_else(|_| "gcc".into()); + let ar = gcc_archiver(&cc).await; + let core = root.join("core.c"); + let optional = root.join("optional.c"); + let dependency = root.join("dependency.c"); + let local_used = root.join("local_used.c"); + let local_sd = root.join("local_sd.c"); + let local_callback = root.join("local_callback.c"); + std::fs::write( + &core, + "int state; __attribute__((constructor)) void core_init(void) { state |= 1; }", + ) + .unwrap(); + std::fs::write(&optional, "extern int state; extern int dependency(void); extern int local_callback(void); __attribute__((constructor)) void optional_init(void) { state |= 2; } int optional(void) { return dependency() + local_callback() - 13; }").unwrap(); + std::fs::write(&dependency, "int dependency(void) { return 7; }").unwrap(); + std::fs::write(&local_used, "int local_value(void) { return 11; }").unwrap(); + std::fs::write( + &local_sd, + "extern int optional(void); int local_sd(void) { return optional(); }", + ) + .unwrap(); + std::fs::write(&local_callback, "int local_callback(void) { return 13; }").unwrap(); + let sources = [ + &core, + &optional, + &dependency, + &local_used, + &local_sd, + &local_callback, + ]; + let mut objects = Vec::new(); + for source in sources { + let object = CompilerBase::object_path(source, root); + run(&[ + cc.clone(), + "-flto".into(), + "-c".into(), + source.display().to_string(), + "-o".into(), + object.display().to_string(), + ]) + .await; + objects.push(object); + } + let optional_objects: Vec<_> = [optional, dependency, local_used, local_sd, local_callback] + .iter() + .map(|source| CompilerBase::object_path(source, root)) + .collect(); + let inputs = archive_optional_library_objects(objects, &optional_objects, root, &ar) + .await + .unwrap(); + for (name, body) in [ + ( + "unused", + "extern int state; extern int local_value(void); int main(void) { return state != 1 || local_value() != 11; }", + ), + ( + "used", + "extern int state; extern int local_value(void); extern int local_sd(void); int main(void) { return state != 3 || local_value() != 11 || local_sd() != 7; }", + ), + ] { + let main = root.join(format!("{name}.c")); + let exe = root.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&main, body).unwrap(); + let mut args = vec![cc.clone(), "-flto".into(), main.display().to_string()]; + args.extend(inputs.iter().map(|path| path.display().to_string())); + args.extend(["-o".into(), exe.display().to_string()]); + run(&args).await; + run(&[exe.display().to_string()]).await; + } + } +} diff --git a/crates/fbuild-library/src/library/esptool.rs b/crates/fbuild-library/src/library/esptool.rs index 978bd135d..0a5498dc8 100644 --- a/crates/fbuild-library/src/library/esptool.rs +++ b/crates/fbuild-library/src/library/esptool.rs @@ -8,7 +8,7 @@ //! provisions esptool as a managed package instead, so no user `pip install` //! is required. //! -//! Modern metadata URLs use the **PyInstaller standalone binary** from +//! Modern metadata URLs on Unix use the **PyInstaller standalone binary** from //! [`tasmota/esptool`](https://github.com/tasmota/esptool). Older pioarduino //! releases instead pin a source-only `pioarduino/esptool` archive; those are //! installed at the exact URL in a private `uv tool` environment so their @@ -20,7 +20,9 @@ //! (`.../download/0.0.1/esptoolpy-v5.3.0.zip` → `5.3.0`) and //! version-in-release-tag (`.../download/v4.8.5/esptool.zip` → `4.8.5`). //! See `extract_esptool_version`. -//! 2. The host `(OS, ARCH)` maps to a tasmota platform tag +//! 2. Windows registry metadata selects the published pioarduino source archive +//! at the same version: some releases (including 5.1.0) have no Windows binary. +//! Other hosts map `(OS, ARCH)` to a tasmota platform tag //! (`linux-amd64`, `macos-arm64`, `windows-amd64`, …). An unsupported host //! yields an error, and the caller falls back to an `esptool` on PATH. //! 3. `https://github.com/tasmota/esptool/releases/download/v{version}/esptool-{platform}.zip` @@ -85,17 +87,42 @@ pub struct Esptool { impl Esptool { /// Create from the `platform.json`-derived `tool-esptoolpy` URL /// (`Esp32Platform::get_package_url("tool-esptoolpy")`). Only the version - /// embedded in the URL is used — from the filename, or from the release - /// tag when the filename is generic. + /// embedded in registry URLs is used — from the filename. Direct source + /// archive URLs are retained, with the version taken from the release tag. pub fn from_metadata_url(project_dir: &Path, metadata_url: &str) -> Self { + Self::from_metadata_url_for_host( + project_dir, + metadata_url, + fbuild_core::platform::host::is_windows(), + ) + } + + fn from_metadata_url_for_host(project_dir: &Path, metadata_url: &str, windows: bool) -> Self { + let version = extract_esptool_version(metadata_url); + let source_url = if metadata_url + .starts_with("https://github.com/pioarduino/esptool/releases/download/") + { + Some(metadata_url.to_string()) + } else if windows + && metadata_url.starts_with("https://github.com/pioarduino/registry/releases/download/") + && version != "unknown" + && metadata_url.ends_with(&format!("/esptoolpy-v{version}.zip")) + { + // This is the any-host source URL published in the registry's + // tools.json, not a renamed binary asset. Keep the SDK's version; + // ensure_source_installed verifies the resulting uv launcher. + Some(format!( + "https://github.com/pioarduino/esptool/releases/download/v{version}/esptool.zip" + )) + } else { + None + }; Self { project_dir: NormalizedPath::from(project_dir), - version: extract_esptool_version(metadata_url), - source_url: metadata_url - .starts_with("https://github.com/pioarduino/esptool/releases/download/") - .then(|| metadata_url.to_string()), + version: version.clone(), + source_url, source_checksum: None, - expected_runtime_version: Some(extract_esptool_version(metadata_url)), + expected_runtime_version: Some(version), } } diff --git a/crates/fbuild-library/src/library/esptool_tests.rs b/crates/fbuild-library/src/library/esptool_tests.rs index f42078c51..6270e58d7 100644 --- a/crates/fbuild-library/src/library/esptool_tests.rs +++ b/crates/fbuild-library/src/library/esptool_tests.rs @@ -339,7 +339,12 @@ fn download_url_matches_the_url_ensure_installed_uses() { ); assert_eq!(esptool.version(), "5.3.0"); - if let Some(tag) = tasmota_platform_tag() { + if fbuild_core::platform::host::is_windows() { + assert_eq!( + esptool.download_url().unwrap(), + "https://github.com/pioarduino/esptool/releases/download/v5.3.0/esptool.zip" + ); + } else if let Some(tag) = tasmota_platform_tag() { assert_eq!( esptool.download_url().unwrap(), Esptool::release_url("5.3.0", tag) @@ -347,6 +352,58 @@ fn download_url_matches_the_url_ensure_installed_uses() { } } +#[test] +fn windows_registry_esptool_uses_published_source_archives() { + let tmp = tempfile::TempDir::new().unwrap(); + // The real 5.1.0 registry tools.json publishes this any-host source URL; + // its upstream release has no esptool-windows-amd64.zip asset. + for version in ["5.1.0", "5.3.0"] { + let metadata = format!( + "https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v{version}.zip" + ); + let source = format!( + "https://github.com/pioarduino/esptool/releases/download/v{version}/esptool.zip" + ); + let windows = Esptool::from_metadata_url_for_host(tmp.path(), &metadata, true); + assert_eq!(windows.version(), version); + assert_eq!(windows.download_url().unwrap(), source); + assert_eq!(windows.source_url.as_deref(), Some(source.as_str())); + assert_eq!(windows.expected_runtime_version.as_deref(), Some(version)); + + let unix = Esptool::from_metadata_url_for_host(tmp.path(), &metadata, false); + assert!(unix.source_url.is_none()); + assert_eq!(unix.version(), version); + } +} + +#[test] +fn windows_source_selection_preserves_direct_and_platformio_archives() { + let tmp = tempfile::TempDir::new().unwrap(); + let source = "https://github.com/pioarduino/esptool/releases/download/v4.7.5/esptool.zip"; + for windows in [false, true] { + let esptool = Esptool::from_metadata_url_for_host(tmp.path(), source, windows); + assert_eq!(esptool.download_url().unwrap(), source); + assert_eq!(esptool.expected_runtime_version.as_deref(), Some("4.7.5")); + } + let payload = fbuild_core::platformio_package::ResolvedPayload { + owner: "platformio".into(), + kind: fbuild_core::platformio_package::PackageKind::Tool, + name: "tool-esptoolpy".into(), + version: "1.30300.0".into(), + system: "all".into(), + url: "https://dl.registry.platformio.org/download/platformio/tool/tool-esptoolpy/1.30300.0/tool-esptoolpy-1.30300.0.tar.gz".into(), + sha256: "published-registry-digest".into(), + }; + let legacy = Esptool::from_registry_payload(tmp.path(), &payload); + assert_eq!(legacy.download_url().unwrap(), payload.url); + assert_eq!(legacy.version(), "1.30300.0"); + assert_eq!( + legacy.source_checksum.as_deref(), + Some(payload.sha256.as_str()) + ); + assert!(legacy.expected_runtime_version.is_none()); +} + #[test] fn pinned_pioarduino_source_archive_is_not_replaced_by_tasmota() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/local-gate.toml b/local-gate.toml index 6ff847945..57bbd8460 100644 --- a/local-gate.toml +++ b/local-gate.toml @@ -18,7 +18,7 @@ mode = "minimal" [[gate.replay.jobs]] source-job = "check-ubuntu.yml:check" key = "linux/Check Ubuntu/Check (ubuntu-latest)" -steps = ["Checkout source", "Verify candidate SHA", "Set up uv", "Select workspace test interpreter", "Setup soldr", "Clippy", "Check tracked Python complexity ratchet", "Lint subprocess spawns", "Guard production USB VID/PID catalogue literals", "Test"] +steps = ["Checkout source", "Verify candidate SHA", "Set up uv", "Select workspace test interpreter", "Setup soldr", "Clippy", "Check tracked Python complexity ratchet", "Lint subprocess spawns", "Guard production USB VID/PID catalogue literals", "Test", "Test optional SDK library linkage with native GCC LTO"] lanes = ["linux-minimal"] [[gate.replay.jobs]] diff --git a/pyproject.toml b/pyproject.toml index 5cd879797..b4f5e5f1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fbuild" -version = "2.5.40" +version = "2.5.41" description = "PlatformIO-compatible embedded build tool (Rust implementation)" readme = "README.md" license = "AGPL-3.0-only" diff --git a/uv.lock b/uv.lock index a190faed3..2dd05a66f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "fbuild" -version = "2.5.40" +version = "2.5.41" source = { editable = "." } [package.dev-dependencies]