Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
7 changes: 4 additions & 3 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions installer/Package.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
Description="$(env.PRODUCT_NAME) command line interface"
InstallScope="perMachine">
<MediaTemplate EmbedCab="yes" />
<!-- Prerelease SemVer suffixes collapse to one MSI major.minor.patch
version, and Windows Installer compares only those first three fields. -->
<MajorUpgrade
AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of [ProductName] is already installed."
Schedule="afterInstallInitialize" />
<WixVariable Id="WixUILicenseRtf" Value="$(env.LICENSE_RTF_PATH)" />
<UI Id="WixUI_Minimal" />
<Feature Id="MainApplication" Title="$(env.PRODUCT_NAME)" Level="1">
Expand Down
12 changes: 12 additions & 0 deletions tests/documentation_installation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
51 changes: 51 additions & 0 deletions tests/installer_package_wxs_tests.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
17 changes: 17 additions & 0 deletions tests/workflow_build_and_package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ fn rust_build_release_step_blocks(contents: &str) -> Vec<String> {
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!(
Expand All @@ -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);
Expand Down
Loading