diff --git a/CHANGELOG.md b/CHANGELOG.md index ef53f01c6..a419f7224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,8 @@ ### Fixed +- Support beta-to-beta and beta-to-final Windows MSI replacement through WiX + major-upgrade metadata. - Expand parent-relative glob patterns such as `glob('../shared/*.h')`; their matches previously reached the working-directory capability as `../…` and were rejected as sandbox escapes diff --git a/Cargo.lock b/Cargo.lock index b02d6e4e5..348a6f476 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1575,6 +1575,7 @@ dependencies = [ "pretty_assertions", "proptest", "regex", + "roxmltree", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -2191,6 +2192,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rstest" version = "0.26.1" diff --git a/Cargo.toml b/Cargo.toml index 4c8185f79..9c7164193 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ thiserror = "1" time = { version = "0.3.44", features = ["formatting"] } tracing = "0.1" [dev-dependencies] +roxmltree = "0.21.1" rstest = "0.26.1" rstest-bdd = "0.5.0" rstest-bdd-macros = { version = "0.5.0", features = ["strict-compile-time-validation"] } diff --git a/docs/users-guide.md b/docs/users-guide.md index 29417f3ef..de83c076b 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -67,9 +67,10 @@ update `PATH`. The MSI installer supports pre-release SemVer versions such as `0.1.0-beta3`: the pre-release suffix cannot be represented in an MSI product version, so the installer carries the numeric release triple (`0.1.0`) while the full version -remains in the package and release names. Because successive pre-releases share -that numeric version, installing a later pre-release MSI replaces the existing -installation for that version series rather than installing alongside it. +remains in the package and release names. Because successive beta and final +releases share that numeric version, installing a later beta or final MSI +replaces the existing installation for that version series rather than +installing alongside it. SHA-256 checksum files accompany standalone binaries and staged help, completion, and licence files. Installer packages do not have checksum sidecars diff --git a/installer/Package.wxs b/installer/Package.wxs index 0e2d8d832..6a42e2048 100644 --- a/installer/Package.wxs +++ b/installer/Package.wxs @@ -9,6 +9,12 @@ Description="$(env.PRODUCT_NAME) command line interface" InstallScope="perMachine"> + + diff --git a/tests/documentation_installation_tests.rs b/tests/documentation_installation_tests.rs index b167c597d..6e3f5f563 100644 --- a/tests/documentation_installation_tests.rs +++ b/tests/documentation_installation_tests.rs @@ -115,6 +115,18 @@ fn assert_release_installation_contract() -> Result<()> { ); } } + let users_guide = + test_fs::read_to_string("docs/users-guide.md").context("read docs/users-guide.md")?; + let expected_msi_replacement_fragments = [ + "installing a later beta or final MSI", + "replaces the existing installation", + ]; + ensure!( + expected_msi_replacement_fragments + .into_iter() + .all(|fragment| users_guide.contains(fragment)), + "users' guide should document MSI replacement within a version series" + ); Ok(()) } diff --git a/tests/installer_package_wxs_tests.rs b/tests/installer_package_wxs_tests.rs new file mode 100644 index 000000000..1f08bc0a9 --- /dev/null +++ b/tests/installer_package_wxs_tests.rs @@ -0,0 +1,51 @@ +//! Validate the `WiX` major-upgrade contract for Windows installer packages. + +use anyhow::{Context, Result, ensure}; +use roxmltree::Document; +use test_support::fs as test_fs; + +/// Verify that `WiX` treats prerelease and final MSI packages as upgrades. +#[test] +fn package_declares_safe_major_upgrade_metadata() -> Result<()> { + let contents = + test_fs::read_to_string("installer/Package.wxs").context("read installer/Package.wxs")?; + let document = Document::parse(&contents).context("parse installer/Package.wxs")?; + let package = document + .root_element() + .children() + .find(|node| node.is_element() && node.tag_name().name() == "Package") + .context("Package.wxs should contain a Package element")?; + + ensure!( + package.attribute("UpgradeCode") == Some("{870359C0-A975-4DCB-992A-AD67D97292DD}"), + "Package should retain its stable UpgradeCode" + ); + ensure!( + package.attribute("Id").is_none(), + "Package should let WiX generate a fresh ProductCode for each build" + ); + + let major_upgrade = package + .children() + .find(|node| node.is_element() && node.tag_name().name() == "MajorUpgrade") + .context("Package should declare a MajorUpgrade element")?; + ensure!( + major_upgrade.attribute("AllowSameVersionUpgrades") == Some("yes"), + "MajorUpgrade should replace prerelease MSI packages with the same numeric version" + ); + ensure!( + major_upgrade.attribute("AllowDowngrades").is_none(), + "MajorUpgrade should continue to block numeric release downgrades" + ); + ensure!( + major_upgrade + .attribute("DowngradeErrorMessage") + .is_some_and(|message| !message.is_empty()), + "MajorUpgrade should explain why a numeric downgrade is blocked" + ); + ensure!( + major_upgrade.attribute("Schedule") == Some("afterInstallInitialize"), + "MajorUpgrade should remove the prior product early enough to roll back safely" + ); + Ok(()) +} diff --git a/tests/workflow_build_and_package.rs b/tests/workflow_build_and_package.rs index 963bac924..c09424422 100644 --- a/tests/workflow_build_and_package.rs +++ b/tests/workflow_build_and_package.rs @@ -144,6 +144,22 @@ fn rust_build_release_step_blocks(contents: &str) -> Vec { blocks } +/// Check that the Windows package action keeps its upgrade family stable. +fn assert_windows_package_upgrade_family_wiring(contents: &str) { + let windows_package_step = + workflow_step_body(contents, "Build Windows installer package").join("\n"); + for expected in [ + "product-name: ${{ inputs['bin-name'] }}", + "install-dir-name: ${{ inputs['bin-name'] }}", + "version: ${{ inputs.version }}", + ] { + assert!( + windows_package_step.contains(expected), + "windows-package should preserve its upgrade family contract: {expected}" + ); + } +} + fn assert_shared_build_skips_man_page_discovery(contents: &str) { let rust_build_steps = rust_build_release_step_blocks(contents); assert!( @@ -162,6 +178,7 @@ fn assert_shared_build_skips_man_page_discovery(contents: &str) { fn behavioural_build_and_package_wiring_matches_shared_actions() { let contents = workflow_contents("build-and-package.yml") .expect("build-and-package workflow should be readable"); + assert_windows_package_upgrade_family_wiring(&contents); // Wiring of the shared build action, which is this test's subject. assert_shared_build_skips_man_page_discovery(&contents);