From eb96c4bab62dc43c5cac16648edce82dcdf76447 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 2 Sep 2026 19:51:56 +0200 Subject: [PATCH 1/2] Restore MSI prerelease upgrades (#656) Configure the WiX package for major upgrades that replace earlier beta packages and promote beta installations to the corresponding final release. Keep the upgrade family stable while allowing WiX to issue a fresh ProductCode for every package, and lock the contract with XML, workflow, and documentation tests. --- CHANGELOG.md | 2 + Cargo.lock | 10 +++++ Cargo.toml | 1 + docs/users-guide.md | 7 ++-- installer/Package.wxs | 6 +++ tests/documentation_installation_tests.rs | 9 ++++ tests/installer_package_wxs_tests.rs | 51 +++++++++++++++++++++++ tests/workflow_build_and_package.rs | 17 ++++++++ 8 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/installer_package_wxs_tests.rs 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..0eae9beb0 100644 --- a/tests/documentation_installation_tests.rs +++ b/tests/documentation_installation_tests.rs @@ -115,6 +115,15 @@ fn assert_release_installation_contract() -> Result<()> { ); } } + let users_guide = + test_fs::read_to_string("docs/users-guide.md").context("read docs/users-guide.md")?; + ensure!( + users_guide.contains(concat!( + "installing a later beta or final MSI\n", + "replaces the existing installation" + )), + "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); From f5e92306571c39ead2d8d9aa2f1e3411d2d1a7aa Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 8 Sep 2026 15:17:32 +0200 Subject: [PATCH 2/2] Make MSI documentation test line-ending agnostic (#656) Assert each required replacement fact independently so the documentation contract works with LF and CRLF checkouts and Markdown wrapping. --- tests/documentation_installation_tests.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/documentation_installation_tests.rs b/tests/documentation_installation_tests.rs index 0eae9beb0..6e3f5f563 100644 --- a/tests/documentation_installation_tests.rs +++ b/tests/documentation_installation_tests.rs @@ -117,11 +117,14 @@ 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!( - users_guide.contains(concat!( - "installing a later beta or final MSI\n", - "replaces the existing installation" - )), + expected_msi_replacement_fragments + .into_iter() + .all(|fragment| users_guide.contains(fragment)), "users' guide should document MSI replacement within a version series" ); Ok(())