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);