Repository: axodotdev/cargo-dist
Pull request #370 (2023-08-28 15:03:22)

feat(msi): add msi installer
The "msi" installer vendors the binaries into a windows msi.
msi generation is implemented by using cargo-wix as a library,
which in turn handles generating templates and invoking WiX (v3).

The resulting msi's are unsigned -- signing will be handled by a followup,
as changes to OV certs mean that "proper" signing is now very complicated
and messy.

This implementation has some notable details:

main.wxs generation
--------------------

WiX requires an xml-based template called main.wxs for each msi it generates.
This template includes things like the application name, version, publisher,
EULA, embedded files, and so on.

cargo-wix's default workflow is for you to run `cargo wix init` once to generate
that file and check it in. At this point the developer is free to hand-edit
the template to suit their needs. We like this idea, `cargo dist generate-ci`
is based on that same premise!

But our experience with generate-ci has also taught us that this approach
is really prone to frustrating desyncs. The main.wxs bakes in several pieces
of information that were sourced from your Cargo.toml. As a result, if you
ever change your Cargo.toml, you now need to remember to apply the same
change to your main.wxs, or rerun `cargo wix init` and potentially clobber
any hand-edits you made.

As such, for the first draft of this feature we've opted for a more aggressive
solution: whenever you run `cargo dist init` we will invoke `cargo wix init`
to force the template to have up to date contents, essentially forbidding
hand-edits.

When you run `cargo dist build` or `cargo dist plan`
we will also error out if we detect that `cargo wix init` would modify
main.wxs.

A new `allow-dirty = ["msi"]` can be added to your cargo-dist config to
prevent `init` from regenerating main.wxs or checking that it's up to date,
essentially restoring the original design of cargo-wix. We believe this
should be opt-in because most people ideally shouldn't want to do hand-edits,
and desyncing is a very nasty footgun.

GUID persistence
----------------

`cargo dist init` will also modify your Cargo.tomls to add
`[package.metadata.wix]` with two generated GUIDs (if those GUIDs aren't
already present).

These GUIDs are used by windows to identify that two different msis actually
intend to refer to the same application and install dir. As such, each release
of your app needs to use the same GUID.

cargo-wix bakes these GUIDs into your main.wxs, and critically it defaults to
generating new random ones every time you run init (because constantly
re-initing wasn't part of the design).

By persisting the GUIDs to your package's Cargo.toml, we unlock the ability
to freely regenerate main.wxs whenever we want.

parasyte: Is there a mode of operation where the platform-specific metadata for the package (`main.wxs` for instance) is recreated on every package build? Something should be capable of creating these kinds of things from a separate source of truth which is, with best effort, shared across all platforms. (I am indifferent to the choice between using cargo metadata or a separate TOML file for the metadata that is only applicable to one specific platform. Though I suspect a separate TOML will be nicer to maintain over time.)

The reason I ask is because it seems like this would be more robust than attempting to merge hand edits to an XML file committed to the repo. And more robust to modifications of the source of truth. So long as the XML can be reproduced deterministically, I don't see any reason at all to commit it.

Perhaps `cargo-wix` is missing some capabilities required to make `main.wxs` transient, and I could see this as an argument against the idea. But I believe it is right path forward. Personally, I am not building a Windows app (I'm building a platform-independent app) and I don't want to maintain Windows-specific details, if I can avoid it.
Gankra: > Is there a mode of operation where the platform-specific metadata for the package (main.wxs for instance) is recreated on every package build

Yes, that's the plan. Some minor upstream work is required to not *at all* check in the file, but I'm personally biased towards  checking it in to make things more... continuous... for lack of a better word. With #381 we'll basically be discouraging hand-editing the file, requiring you to opt into being allowed to. The implementation in this PR currently persists all the necessary state to your Cargo.toml to be able to regenerate it reproducibly every time.

At very least this MVP impl will probably require checking it in, and we might make it totally hidden in future releases when we've had more time to work the details out.
Gankra: The code is ready for review but landing is blocked on https://github.com/volks73/cargo-wix/issues/219
Gankra: depends on https://github.com/volks73/cargo-wix/pull/222

Patch:
diff --git a/.gitignore b/.gitignore
index bf77cdd8..79c04f87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
 /book/book/
 oranda-debug.log
 cargo-dist/public
+cargo-dist/wix
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95c86288..e86db190 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,7 +44,7 @@ If you enable any of these features, we may automatically turn on `precise-build
 
 See the docs for all the details.
 
-* @gankra + @Yatekiii [impl](https://github.com/axodotdev/cargo-dist/pull/321)
+* @gankra + @Yatekii [impl](https://github.com/axodotdev/cargo-dist/pull/321)
 * docs
     * [features](https://opensource.axo.dev/cargo-dist/book/config.html#features)
     * [all-features](https://opensource.axo.dev/cargo-dist/book/config.html#all-features)
diff --git a/Cargo.lock b/Cargo.lock
index 9941b05b..c8aa683e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -37,6 +37,21 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "anstream"
 version = "0.5.0"
@@ -288,6 +303,7 @@ dependencies = [
  "axoproject",
  "camino",
  "cargo-dist-schema",
+ "cargo-wix",
  "cargo_metadata 0.17.0",
  "clap",
  "clap-cargo",
@@ -310,6 +326,7 @@ dependencies = [
  "thiserror",
  "toml_edit",
  "tracing",
+ "uuid",
 ]
 
 [[package]]
@@ -333,6 +350,32 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "cargo-wix"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73b290f8aa497e7720f9594f7ca4809e7f2b06978f4993e85227be26840e7448"
+dependencies = [
+ "camino",
+ "cargo_metadata 0.17.0",
+ "chrono",
+ "clap",
+ "encoding_rs_io",
+ "env_logger",
+ "itertools 0.11.0",
+ "lazy_static",
+ "log 0.4.20",
+ "mustache",
+ "regex",
+ "rustc-cfg",
+ "semver",
+ "serde_json",
+ "sxd-document",
+ "sxd-xpath",
+ "termcolor",
+ "uuid",
+]
+
 [[package]]
 name = "cargo_metadata"
 version = "0.15.4"
@@ -387,6 +430,21 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "chrono"
+version = "0.4.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "time 0.1.45",
+ "wasm-bindgen",
+ "winapi",
+]
+
 [[package]]
 name = "cipher"
 version = "0.4.4"
@@ -479,6 +537,12 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
 
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
+
 [[package]]
 name = "cpufeatures"
 version = "0.2.9"
@@ -634,6 +698,28 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "encoding_rs_io"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0"
+dependencies = [
+ "humantime",
+ "is-terminal",
+ "log 0.4.20",
+ "regex",
+ "termcolor",
+]
+
 [[package]]
 name = "equivalent"
 version = "1.0.1"
@@ -819,7 +905,7 @@ dependencies = [
  "cfg-if",
  "js-sys",
  "libc",
- "wasi",
+ "wasi 0.11.0+wasi-snapshot-preview1",
  "wasm-bindgen",
 ]
 
@@ -966,6 +1052,12 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
 [[package]]
 name = "hyper"
 version = "0.14.27"
@@ -1004,6 +1096,29 @@ dependencies = [
  "tokio-rustls",
 ]
 
+[[package]]
+name = "iana-time-zone"
+version = "0.1.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
 [[package]]
 name = "idna"
 version = "0.4.0"
@@ -1216,6 +1331,15 @@ dependencies = [
  "scopeguard",
 ]
 
+[[package]]
+name = "log"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b"
+dependencies = [
+ "log 0.4.20",
+]
+
 [[package]]
 name = "log"
 version = "0.4.20"
@@ -1322,10 +1446,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
 dependencies = [
  "libc",
- "wasi",
+ "wasi 0.11.0+wasi-snapshot-preview1",
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "mustache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51956ef1c5d20a1384524d91e616fb44dfc7d8f249bf696d49c97dd3289ecab5"
+dependencies = [
+ "log 0.3.9",
+ "serde",
+]
+
 [[package]]
 name = "nanorand"
 version = "0.7.0"
@@ -1481,6 +1615,12 @@ version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
 
+[[package]]
+name = "peresil"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57"
+
 [[package]]
 name = "petgraph"
 version = "0.6.4"
@@ -1560,6 +1700,12 @@ dependencies = [
  "bytemuck",
 ]
 
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
 [[package]]
 name = "quote"
 version = "1.0.33"
@@ -1653,7 +1799,7 @@ dependencies = [
  "hyper-rustls",
  "ipnet",
  "js-sys",
- "log",
+ "log 0.4.20",
  "mime",
  "once_cell",
  "percent-encoding",
@@ -1689,6 +1835,15 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "rustc-cfg"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ddf7a5e441e8003a5a88aab97f1c6113043ddde252d789ef9dea3871b78633a"
+dependencies = [
+ "thiserror",
+]
+
 [[package]]
 name = "rustc-demangle"
 version = "0.1.23"
@@ -1714,7 +1869,7 @@ version = "0.21.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8"
 dependencies = [
- "log",
+ "log 0.4.20",
  "ring",
  "rustls-webpki",
  "sct",
@@ -2014,6 +2169,27 @@ dependencies = [
  "is-terminal",
 ]
 
+[[package]]
+name = "sxd-document"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078"
+dependencies = [
+ "peresil",
+ "typed-arena",
+]
+
+[[package]]
+name = "sxd-xpath"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea"
+dependencies = [
+ "peresil",
+ "quick-error",
+ "sxd-document",
+]
+
 [[package]]
 name = "syn"
 version = "1.0.109"
@@ -2077,6 +2253,15 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "termcolor"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6"
+dependencies = [
+ "winapi-util",
+]
+
 [[package]]
 name = "terminal_size"
 version = "0.1.17"
@@ -2139,6 +2324,17 @@ dependencies = [
  "weezl",
 ]
 
+[[package]]
+name = "time"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
+dependencies = [
+ "libc",
+ "wasi 0.10.0+wasi-snapshot-preview1",
+ "winapi",
+]
+
 [[package]]
 name = "time"
 version = "0.3.28"
@@ -2249,7 +2445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
 dependencies = [
  "cfg-if",
- "log",
+ "log 0.4.20",
  "pin-project-lite",
  "tracing-attributes",
  "tracing-core",
@@ -2283,7 +2479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922"
 dependencies = [
  "lazy_static",
- "log",
+ "log 0.4.20",
  "tracing-core",
 ]
 
@@ -2307,6 +2503,12 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
 
+[[package]]
+name = "typed-arena"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d"
+
 [[package]]
 name = "typenum"
 version = "1.16.0"
@@ -2375,6 +2577,15 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
 
+[[package]]
+name = "uuid"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
+dependencies = [
+ "getrandom",
+]
+
 [[package]]
 name = "valuable"
 version = "0.1.0"
@@ -2406,6 +2617,12 @@ dependencies = [
  "try-lock",
 ]
 
+[[package]]
+name = "wasi"
+version = "0.10.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
+
 [[package]]
 name = "wasi"
 version = "0.11.0+wasi-snapshot-preview1"
@@ -2429,7 +2646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
 dependencies = [
  "bumpalo",
- "log",
+ "log 0.4.20",
  "once_cell",
  "proc-macro2",
  "quote",
@@ -2531,6 +2748,15 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
+[[package]]
+name = "windows"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.45.0"
@@ -2731,7 +2957,7 @@ dependencies = [
  "hmac",
  "pbkdf2",
  "sha1",
- "time",
+ "time 0.3.28",
  "zstd",
 ]
 
diff --git a/book/src/concepts.md b/book/src/concepts.md
index de33ba7c..70ff7ae4 100644
--- a/book/src/concepts.md
+++ b/book/src/concepts.md
@@ -124,7 +124,7 @@ Normally cargo-dist will error out if the Announcement Tag selects no Apps, beca
 
 Now that we have a coherent Announcement and therefore have selected what apps we want to Release, we need to select what artifacts we want to build (or get a manifest for). Enumerating the exact artifacts for each invocation of cargo-dist would be tedious and error-prone, so we provide the `--artifacts=...` flag to specify the *Artifact Mode*, which is a certain subset of the Universe of all Artifacts:
 
-* "local": artifacts that are per-target platform ([executable-zips][executable-zip], symbols, MSIs...)
+* "local": artifacts that are per-target platform ([executable-zips][executable-zip], symbols, msi installers...)
 * "global": artifacts that are one-per-app (shell installer, npm package...)
 * "all": both global and local (so the whole Universe)
 * "host": the default mode that kind of breaks the rules to let you test things out locally
diff --git a/cargo-dist/Cargo.toml b/cargo-dist/Cargo.toml
index 1d7118ba..9c3788b0 100644
--- a/cargo-dist/Cargo.toml
+++ b/cargo-dist/Cargo.toml
@@ -51,6 +51,8 @@ sha2 = "0.10.6"
 minijinja = { version = "1.0.5", features = ["debug", "loader", "builtins", "json", "custom_syntax"] }
 include_dir = "0.7.3"
 itertools = "0.11.0"
+cargo-wix = "0.3.6"
+uuid = { version = "1", features = ["v4"] }
 
 [dev-dependencies]
 insta = { version = "1.26.0", features = ["filters"] }
diff --git a/cargo-dist/src/backend/installer/mod.rs b/cargo-dist/src/backend/installer/mod.rs
index 03c77e49..fdb6570e 100644
--- a/cargo-dist/src/backend/installer/mod.rs
+++ b/cargo-dist/src/backend/installer/mod.rs
@@ -11,9 +11,11 @@ use crate::{
 };
 
 use self::homebrew::HomebrewInstallerInfo;
+use self::msi::MsiInstallerInfo;
 use self::npm::NpmInstallerInfo;
 
 pub mod homebrew;
+pub mod msi;
 pub mod npm;
 pub mod powershell;
 pub mod shell;
@@ -30,6 +32,8 @@ pub enum InstallerImpl {
     Npm(NpmInstallerInfo),
     /// Homebrew formula
     Homebrew(HomebrewInstallerInfo),
+    /// Windows msi installer
+    Msi(MsiInstallerInfo),
 }
 
 /// Generic info about an installer
diff --git a/cargo-dist/src/backend/installer/msi.rs b/cargo-dist/src/backend/installer/msi.rs
new file mode 100644
index 00000000..7dbbc7bb
--- /dev/null
+++ b/cargo-dist/src/backend/installer/msi.rs
@@ -0,0 +1,162 @@
+//! msi installer
+
+use axoasset::LocalAsset;
+use camino::Utf8PathBuf;
+use tracing::info;
+
+use crate::{backend::diff_files, config, errors::*};
+
+const METADATA_WIX: &str = "wix";
+const WIX_GUID_KEYS: &[&str] = &["upgrade-guid", "path-guid"];
+
+/// Info needed to build an msi
+#[derive(Debug, Clone)]
+pub struct MsiInstallerInfo {
+    /// An ideally unambiguous way to refer to a package for the purpose of cargo -p flags.
+    pub pkg_spec: String,
+    /// Binaries we'll be baking into the msi
+    pub target: String,
+    /// Final file path of the msi
+    pub file_path: Utf8PathBuf,
+    /// Dir stuff goes to
+    pub package_dir: Utf8PathBuf,
+    /// Path to the wxs file this installer uses
+    pub wxs_path: Utf8PathBuf,
+    /// Path to the package Cargo.toml associated with this msi
+    pub manifest_path: Utf8PathBuf,
+}
+
+impl MsiInstallerInfo {
+    /// Build the msi installer
+    ///
+    /// Note that this assumes `write_wsx_to_disk` was run beforehand (via `cargo dist generate`),
+    /// which should be enforced by `check_wsx` (via `cargo dist generate --check`).
+    pub fn build(&self) -> DistResult<()> {
+        info!("building an msi: {}", self.file_path);
+
+        let mut b = wix::create::Builder::new();
+        // Build this specific package
+        b.package(Some(&self.pkg_spec));
+        // cargo-dist already did the build for us
+        b.no_build(true);
+        // It built with the `dist` profile
+        b.profile(Some("dist"));
+        // It explicitly built with this --target
+        b.target(Some(&self.target));
+        // We want the output to go here
+        b.output(Some(self.file_path.as_str()));
+        // Binaries are over here
+        b.target_bin_dir(Some(self.package_dir.as_str()));
+        // Give users better feedback from WiX
+        b.capture_output(false);
+
+        let exec = b.build();
+        exec.run().map_err(|e| DistError::Wix {
+            msi: self.file_path.file_name().unwrap().to_owned(),
+            details: e,
+        })?;
+
+        assert!(self.file_path.exists());
+        Ok(())
+    }
+
+    /// run `cargo wix print wxs` to get what the msi should contain
+    pub fn generate_wxs_string(&self) -> DistResult<String> {
+        let mut b = wix::print::wxs::Builder::new();
+        // Build this specific package
+        b.package(Some(&self.pkg_spec));
+        let exec = b.build();
+        let wsx = exec.render_to_string().map_err(|e| DistError::WixInit {
+            package: self.pkg_spec.clone(),
+            details: e,
+        })?;
+        Ok(wsx)
+    }
+
+    /// msi's impl of `cargo dist genenerate --check`
+    pub fn check_config(&self) -> DistResult<()> {
+        self.check_wix_guids()?;
+        self.check_wxs()?;
+        Ok(())
+    }
+    /// msi's impl of `cargo dist genenerate`
+    pub fn write_config_to_disk(&self) -> DistResult<()> {
+        self.write_wix_guids_to_disk()?;
+        self.write_wxs_to_disk()?;
+        Ok(())
+    }
+
+    /// Write the wxs to disk
+    fn write_wxs_to_disk(&self) -> DistResult<()> {
+        let file = &self.wxs_path;
+        let rendered = self.generate_wxs_string()?;
+
+        LocalAsset::write_new_all(&rendered, file)?;
+        eprintln!("generated msi definition to {}", file);
+
+        Ok(())
+    }
+
+    /// Check whether the new configuration differs from the config on disk
+    /// writhout actually writing the result.
+    fn check_wxs(&self) -> DistResult<()> {
+        let existing = &self.wxs_path;
+
+        let rendered = self.generate_wxs_string()?;
+        diff_files(existing, &rendered)
+    }
+
+    /// Check that wix GUIDs are set in the package's Cargo.toml
+    fn check_wix_guids(&self) -> DistResult<()> {
+        // Ok we have changes to make, let's load the toml
+        let mut package_toml = config::load_cargo_toml(&self.manifest_path)?;
+        if update_wix_metadata(&mut package_toml) {
+            Err(DistError::MissingWixGuids {
+                manifest_path: self.manifest_path.clone(),
+                keys: WIX_GUID_KEYS,
+            })
+        } else {
+            Ok(())
+        }
+    }
+
+    /// Write wix GUIDs to the package's Cargo.toml
+    fn write_wix_guids_to_disk(&self) -> DistResult<()> {
+        let mut package_toml = config::load_cargo_toml(&self.manifest_path)?;
+        if update_wix_metadata(&mut package_toml) {
+            config::save_cargo_toml(&self.manifest_path, package_toml)?;
+        }
+        Ok(())
+    }
+}
+
+/// Ensure [package.metadata.wix] has persisted GUIDs.
+///
+/// This ensures that regenerating the installer produces a stable result.
+/// Returns whether modifications were made (and should be written to disk)
+fn update_wix_metadata(package_toml: &mut toml_edit::Document) -> bool {
+    let metadata = config::get_toml_metadata(package_toml, false);
+
+    // Get the subtable
+    let wix_metadata = &mut metadata[METADATA_WIX];
+    // If there's no table, make one
+    if !wix_metadata.is_table() {
+        *wix_metadata = toml_edit::table();
+    }
+    let table = wix_metadata.as_table_mut().unwrap();
+
+    // Ensure the GUIDs exist, generating them if not
+    let mut modified = false;
+    for key in WIX_GUID_KEYS {
+        if !table.contains_key(key) {
+            modified = true;
+            let val = uuid::Uuid::new_v4()
+                .as_hyphenated()
+                .to_string()
+                .to_uppercase();
+            table.insert(key, toml_edit::value(val));
+        }
+    }
+
+    modified
+}
diff --git a/cargo-dist/src/cli.rs b/cargo-dist/src/cli.rs
index c151ad87..02bb7ef2 100644
--- a/cargo-dist/src/cli.rs
+++ b/cargo-dist/src/cli.rs
@@ -173,7 +173,7 @@ pub struct BuildArgs {
     /// Which subset of the Artifacts to build
     ///
     /// Artifacts can be broken up into two major classes: "local" ones, which are
-    /// made for each target system (executable-zips, symbols, MSIs...); and "global" ones,
+    /// made for each target system (executable-zips, symbols, msi installers...); and "global" ones,
     /// which are made once per app (curl-sh installers, npm package, metadata...).
     ///
     /// Having this distinction lets us run cargo-dist independently on
@@ -196,7 +196,7 @@ pub struct BuildArgs {
 /// How we should select the artifacts to build
 #[derive(ValueEnum, Copy, Clone, Debug)]
 pub enum ArtifactMode {
-    /// Build target-specific artifacts like executable-zips and MSIs
+    /// Build target-specific artifacts like executable-zips and msi installers
     Local,
     /// Build unique artifacts like curl-sh installers and npm packages
     Global,
@@ -245,6 +245,8 @@ pub struct InitArgs {
 pub enum GenerateMode {
     /// Generate CI scripts for orchestrating cargo-dist
     Ci,
+    /// Generate .wxs tempaltes for msi installers
+    Msi,
 }
 
 impl GenerateMode {
@@ -252,6 +254,7 @@ impl GenerateMode {
     pub fn to_lib(self) -> cargo_dist::config::GenerateMode {
         match self {
             GenerateMode::Ci => cargo_dist::config::GenerateMode::Ci,
+            GenerateMode::Msi => cargo_dist::config::GenerateMode::Msi,
         }
     }
 }
@@ -305,6 +308,8 @@ pub enum InstallerStyle {
     Npm,
     /// Generates a Homebrew formula
     Homebrew,
+    /// Generates an msi for each windows platform
+    Msi,
 }
 
 impl InstallerStyle {
@@ -315,6 +320,7 @@ impl InstallerStyle {
             InstallerStyle::Powershell => cargo_dist::config::InstallerStyle::Powershell,
             InstallerStyle::Npm => cargo_dist::config::InstallerStyle::Npm,
             InstallerStyle::Homebrew => cargo_dist::config::InstallerStyle::Homebrew,
+            InstallerStyle::Msi => cargo_dist::config::InstallerStyle::Msi,
         }
     }
 }
diff --git a/cargo-dist/src/config.rs b/cargo-dist/src/config.rs
index 5fbfbbe3..d14c827c 100644
--- a/cargo-dist/src/config.rs
+++ b/cargo-dist/src/config.rs
@@ -358,7 +358,7 @@ impl DistMetadata {
         if create_release.is_some() {
             warn!("package.metadata.dist.create-release is set, but this is only accepted in workspace.metadata (value is being ignored): {}", package_manifest_path);
         }
-        // Arguably should be package-local for things like MSIs, but doesn't make sense for CI,
+        // Arguably should be package-local for things like msi installers, but doesn't make sense for CI,
         // so let's not support that yet for its complexity!
         if allow_dirty.is_some() {
             warn!("package.metadata.dist.allow-dirty is set, but this is only accepted in workspace.metadata (value is being ignored): {}", package_manifest_path);
@@ -449,7 +449,7 @@ pub struct Config {
 /// How we should select the artifacts to build
 #[derive(Clone, Copy, Debug)]
 pub enum ArtifactMode {
-    /// Build target-specific artifacts like executable-zips, symbols, MSIs...
+    /// Build target-specific artifacts like executable-zips, symbols, msi installers
     Local,
     /// Build globally unique artifacts like curl-sh installers, npm packages, metadata...
     Global,
@@ -491,6 +491,9 @@ pub enum InstallerStyle {
     /// Generate a Homebrew formula that fetches from [`crate::tasks::DistGraph::artifact_download_url`][]
     #[serde(rename = "homebrew")]
     Homebrew,
+    /// Generate an msi installer that embeds the binary
+    #[serde(rename = "msi")]
+    Msi,
 }
 
 impl std::fmt::Display for InstallerStyle {
@@ -500,6 +503,7 @@ impl std::fmt::Display for InstallerStyle {
             InstallerStyle::Powershell => "powershell",
             InstallerStyle::Npm => "npm",
             InstallerStyle::Homebrew => "homebrew",
+            InstallerStyle::Msi => "msi",
         };
         string.fmt(f)
     }
@@ -529,6 +533,8 @@ pub enum ZipStyle {
     Zip,
     /// `.tar.<compression>`
     Tar(CompressionImpl),
+    /// Don't bundle/compress this, it's just a temp dir
+    TempDir,
 }
 
 /// Compression impls (used by [`ZipStyle::Tar`][])
@@ -545,6 +551,7 @@ impl ZipStyle {
     /// Get the extension used for this kind of zip
     pub fn ext(&self) -> &'static str {
         match self {
+            ZipStyle::TempDir => "",
             ZipStyle::Zip => ".zip",
             ZipStyle::Tar(compression) => match compression {
                 CompressionImpl::Gzip => ".tar.gz",
@@ -752,12 +759,16 @@ pub enum GenerateMode {
     /// Generate CI scripts for orchestrating cargo-dist
     #[serde(rename = "ci")]
     Ci,
+    /// Generate wsx (WiX) templates for msi installers
+    #[serde(rename = "msi")]
+    Msi,
 }
 
 impl std::fmt::Display for GenerateMode {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
             GenerateMode::Ci => "ci".fmt(f),
+            GenerateMode::Msi => "msi".fmt(f),
         }
     }
 }
@@ -826,15 +837,34 @@ pub fn get_project() -> Result<axoproject::WorkspaceInfo> {
 }
 
 /// Load a Cargo.toml into toml-edit form
-pub fn load_cargo_toml(manifest_path: &Utf8Path) -> Result<toml_edit::Document> {
+pub fn load_cargo_toml(manifest_path: &Utf8Path) -> DistResult<toml_edit::Document> {
     let src = axoasset::SourceFile::load_local(manifest_path)?;
     let toml = src.deserialize_toml_edit()?;
     Ok(toml)
 }
 
 /// Save a Cargo.toml from toml-edit form
-pub fn save_cargo_toml(manifest_path: &Utf8Path, toml: toml_edit::Document) -> Result<()> {
+pub fn save_cargo_toml(manifest_path: &Utf8Path, toml: toml_edit::Document) -> DistResult<()> {
     let toml_text = toml.to_string();
     axoasset::LocalAsset::write_new(&toml_text, manifest_path)?;
     Ok(())
 }
+
+/// Get the `[workspace.metadata]` or `[package.metadata]` (based on `is_workspace`)
+pub fn get_toml_metadata(
+    toml: &mut toml_edit::Document,
+    is_workspace: bool,
+) -> &mut toml_edit::Item {
+    // Walk down/prepare the components...
+    let root_key = if is_workspace { "workspace" } else { "package" };
+    let workspace = toml[root_key].or_insert(toml_edit::table());
+    if let Some(t) = workspace.as_table_mut() {
+        t.set_implicit(true)
+    }
+    let metadata = workspace["metadata"].or_insert(toml_edit::table());
+    if let Some(t) = metadata.as_table_mut() {
+        t.set_implicit(true)
+    }
+
+    metadata
+}
diff --git a/cargo-dist/src/errors.rs b/cargo-dist/src/errors.rs
index 05035cb2..394f740b 100644
--- a/cargo-dist/src/errors.rs
+++ b/cargo-dist/src/errors.rs
@@ -43,6 +43,26 @@ pub enum DistError {
         details: minijinja::Error,
     },
 
+    /// Error from (cargo-)wix
+    #[error("WiX returned an error while building {msi}")]
+    Wix {
+        /// The msi we were trying to build
+        msi: String,
+        /// The underyling wix error
+        #[source]
+        details: wix::Error,
+    },
+
+    /// Error from (cargo-)wix init
+    #[error("Couldn't generate main.wxs for {package}'s msi installer")]
+    WixInit {
+        /// The package
+        package: String,
+        /// The underlying wix error
+        #[source]
+        details: wix::Error,
+    },
+
     /// Error parsing metadata in Cargo.toml (json because it's from cargo-metadata)
     #[error("Malformed metadata.dist in {manifest_path}")]
     CargoTomlParse {
@@ -114,7 +134,7 @@ pub enum DistError {
 
     /// Use explicitly requested workspace builds, but had packages with custom feature settings
     #[error("precise-builds = false was set, but some packages have custom build features, making it impossible")]
-    #[help("these packages customized either features, no-default-features, or all-features: {packages:?}")]
+    #[diagnostic(help("these packages customized either features, no-default-features, or all-features: {packages:?}"))]
     PreciseImpossible {
         /// names of problem packages
         packages: Vec<String>,
@@ -159,7 +179,6 @@ pub enum DistError {
         #[source]
         details: semver::Error,
     },
-
     /// Not an error; indicates that a file's contents differ via --check
     #[error("{}:{line_number} has out of date contents and needs to be regenerated:\n-{existing_line}\n+{new_line}", file.origin_path())]
     #[diagnostic(help("run 'cargo dist init' to update the file or set 'allow-dirty' in Cargo.toml to ignore out of date contents"))]
@@ -183,6 +202,33 @@ pub enum DistError {
         /// The problematic mode
         generate_mode: crate::config::GenerateMode,
     },
+    /// msi with too many packages
+    #[error("{artifact_name} depends on multiple packages, which isn't yet supported")]
+    #[diagnostic(help("depends on {spec1} and {spec2}"))]
+    MultiPackageMsi {
+        /// Name of the msi
+        artifact_name: String,
+        /// One of the pacakges
+        spec1: String,
+        /// A different package
+        spec2: String,
+    },
+    /// msi with too few packages
+    #[error("{artifact_name} has no binaries")]
+    #[diagnostic(help("This should be impossible, you did nothing wrong, please file an issue!"))]
+    NoPackageMsi {
+        /// Name of the msi
+        artifact_name: String,
+    },
+    /// These GUIDs for msi's are required and enforced by `cargo dist generate --check`
+    #[error("missing WiX GUIDs in {manifest_path}: {keys:?}")]
+    #[diagnostic(help("run 'cargo dist init' to generate them"))]
+    MissingWixGuids {
+        /// The Cargo.toml missing them
+        manifest_path: Utf8PathBuf,
+        /// The missing keys
+        keys: &'static [&'static str],
+    },
 }
 
 impl From<minijinja::Error> for DistError {
diff --git a/cargo-dist/src/init.rs b/cargo-dist/src/init.rs
index c8eb8ddb..d8b41ed2 100644
--- a/cargo-dist/src/init.rs
+++ b/cargo-dist/src/init.rs
@@ -79,26 +79,39 @@ pub fn do_init(cfg: &Config, args: &InitArgs) -> Result<()> {
     };
 
     if let Some(meta) = &multi_meta.workspace {
-        update_toml_metadata(&mut workspace_toml, meta, true);
+        let metadata = config::get_toml_metadata(&mut workspace_toml, true);
+        apply_dist_to_metadata(metadata, meta);
     }
 
     // Save the workspace toml (potentially an effective no-op if we made no edits)
+    config::save_cargo_toml(&workspace.manifest_path, workspace_toml)?;
     eprintln!("{check} added [workspace.metadata.dist] to your root Cargo.toml");
     eprintln!();
-    config::save_cargo_toml(&workspace.manifest_path, workspace_toml)?;
 
     // Now that we've done the stuff that's definitely part of the root Cargo.toml,
-    // Optionally apply updates to packages (currently only applies with --with-json-config)
-    for (package_name, meta) in &multi_meta.packages {
-        for (_idx, package) in workspace.packages() {
-            if &package.name == package_name {
-                let mut package_toml = config::load_cargo_toml(&package.manifest_path)?;
-                update_toml_metadata(&mut package_toml, meta, false);
-                eprintln!("{check} added [package.metadata.dist] to {package_name}'s Cargo.toml");
-                eprintln!();
-                config::save_cargo_toml(&package.manifest_path, package_toml)?;
-                break;
+    // Optionally apply updates to packages
+    for (_idx, package) in workspace.packages() {
+        // Gather up all the things we'd like to be written to this file
+        let meta = multi_meta.packages.get(&package.name);
+        let needs_edit = meta.is_some();
+
+        if needs_edit {
+            // Ok we have changes to make, let's load the toml
+            let mut package_toml = config::load_cargo_toml(&package.manifest_path)?;
+            let metadata = config::get_toml_metadata(&mut package_toml, false);
+
+            // Apply [package.metadata.dist]
+            if let Some(meta) = meta {
+                apply_dist_to_metadata(metadata, meta);
+                eprintln!(
+                    "{check} added [package.metadata.dist] to {}'s Cargo.toml",
+                    package.name
+                );
             }
+
+            // Save the result
+            eprintln!();
+            config::save_cargo_toml(&package.manifest_path, package_toml)?;
         }
     }
 
@@ -428,7 +441,7 @@ fn get_new_dist_metadata(
     }
 
     // Enable installer backends (if they have a CI backend that can provide URLs)
-    // In the future, "vendored" installers like MSIs could be enabled in this situation!
+    // FIXME: "vendored" installers like msi could be enabled without any CI...
     let has_ci = meta.ci.as_ref().map(|ci| !ci.is_empty()).unwrap_or(false);
     if has_ci {
         let known = &[
@@ -436,6 +449,7 @@ fn get_new_dist_metadata(
             InstallerStyle::Powershell,
             InstallerStyle::Npm,
             InstallerStyle::Homebrew,
+            InstallerStyle::Msi,
         ];
         let mut defaults = vec![];
         let mut keys = vec![];
@@ -459,6 +473,7 @@ fn get_new_dist_metadata(
                 InstallerStyle::Powershell => "powershell",
                 InstallerStyle::Npm => "npm",
                 InstallerStyle::Homebrew => "homebrew",
+                InstallerStyle::Msi => "msi",
             });
         }
 
@@ -630,21 +645,8 @@ fn get_new_dist_metadata(
     Ok(meta)
 }
 
-fn update_toml_metadata(
-    workspace_toml: &mut toml_edit::Document,
-    meta: &DistMetadata,
-    is_workspace: bool,
-) {
-    // Walk down/prepare the components...
-    let root_key = if is_workspace { "workspace" } else { "package" };
-    let workspace = workspace_toml[root_key].or_insert(toml_edit::table());
-    if let Some(t) = workspace.as_table_mut() {
-        t.set_implicit(true)
-    }
-    let metadata = workspace["metadata"].or_insert(toml_edit::table());
-    if let Some(t) = metadata.as_table_mut() {
-        t.set_implicit(true)
-    }
+/// Ensure [*.metadata.dist] has the given values
+fn apply_dist_to_metadata(metadata: &mut toml_edit::Item, meta: &DistMetadata) {
     let dist_metadata = &mut metadata[METADATA_DIST];
 
     // If there's no table, make one
diff --git a/cargo-dist/src/lib.rs b/cargo-dist/src/lib.rs
index 00ba1f87..1f4e5e3f 100644
--- a/cargo-dist/src/lib.rs
+++ b/cargo-dist/src/lib.rs
@@ -249,6 +249,11 @@ fn manifest_artifact(
             description = Some(info.desc.clone());
             kind = cargo_dist_schema::ArtifactKind::Installer;
         }
+        ArtifactKind::Installer(InstallerImpl::Msi(..)) => {
+            install_hint = None;
+            description = Some("install via msi".to_owned());
+            kind = cargo_dist_schema::ArtifactKind::Installer;
+        }
         ArtifactKind::Checksum(_) => {
             install_hint = None;
             description = None;
@@ -314,6 +319,7 @@ fn generate_and_write_checksum(
 
 /// Generate a checksum for the src_path and return it as a string
 fn generate_checksum(checksum: &ChecksumStyle, src_path: &Utf8Path) -> DistResult<String> {
+    info!("generating {checksum:?} for {src_path}");
     use sha2::Digest;
     use std::fmt::Write;
 
@@ -561,6 +567,9 @@ fn zip_dir(
         ZipStyle::Tar(CompressionImpl::Zstd) => {
             LocalAsset::tar_zstd_dir(src_path, dest_path, with_root)?
         }
+        ZipStyle::TempDir => {
+            // no-op
+        }
     }
     Ok(())
 }
@@ -617,12 +626,7 @@ pub fn run_generate(dist: &DistGraph, args: &GenerateArgs) -> Result<()> {
     // Otherwise, choose any modes that are appropriate
     let inferred = args.modes.is_empty();
     let modes = if inferred {
-        let mut m = vec![];
-        // CI is the only thing to infer at the moment
-        if !&dist.ci_style.is_empty() {
-            m.push(GenerateMode::Ci)
-        }
-        m
+        &[GenerateMode::Ci, GenerateMode::Msi]
     } else {
         // Check that we're not being told to do a contradiction
         for &mode in &args.modes {
@@ -634,14 +638,15 @@ pub fn run_generate(dist: &DistGraph, args: &GenerateArgs) -> Result<()> {
                 })?;
             }
         }
-        args.modes.to_owned()
+        &args.modes[..]
     };
 
     // generate everything we need to
-    for mode in modes {
-        match mode {
-            GenerateMode::Ci => {
-                if dist.allow_dirty.should_run(mode) {
+    // HEY! if you're adding a case to this, add it to the inferred list above!
+    for &mode in modes {
+        if dist.allow_dirty.should_run(mode) {
+            match mode {
+                GenerateMode::Ci => {
                     // If you add a CI backend, call it here
                     let CiInfo { github } = &dist.ci;
                     if let Some(github) = github {
@@ -652,6 +657,17 @@ pub fn run_generate(dist: &DistGraph, args: &GenerateArgs) -> Result<()> {
                         }
                     }
                 }
+                GenerateMode::Msi => {
+                    for artifact in &dist.artifacts {
+                        if let ArtifactKind::Installer(InstallerImpl::Msi(msi)) = &artifact.kind {
+                            if args.check {
+                                msi.check_config()?;
+                            } else {
+                                msi.write_config_to_disk()?;
+                            }
+                        }
+                    }
+                }
             }
         }
     }
@@ -699,6 +715,7 @@ fn generate_installer(dist: &DistGraph, style: &InstallerImpl) -> Result<()> {
         InstallerImpl::Homebrew(info) => {
             installer::homebrew::write_homebrew_formula(&dist.templates, dist, info)?
         }
+        InstallerImpl::Msi(info) => info.build()?,
     }
     Ok(())
 }
diff --git a/cargo-dist/src/main.rs b/cargo-dist/src/main.rs
index ee762c05..16aba48d 100644
--- a/cargo-dist/src/main.rs
+++ b/cargo-dist/src/main.rs
@@ -292,10 +292,10 @@ fn print_help_markdown(out: &mut dyn Write) -> std::io::Result<()> {
     let mut fake_cli = FakeCli::command().term_width(0);
     let full_command = fake_cli.get_subcommands_mut().next().unwrap();
     full_command.build();
-    let mut todo = vec![full_command];
+    let mut work_stack = vec![full_command];
     let mut is_full_command = true;
 
-    while let Some(command) = todo.pop() {
+    while let Some(command) = work_stack.pop() {
         let mut help_buf = Vec::new();
         command.write_long_help(&mut help_buf)?;
         let help = String::from_utf8(help_buf).unwrap();
@@ -406,11 +406,11 @@ fn print_help_markdown(out: &mut dyn Write) -> std::io::Result<()> {
         }
         writeln!(out)?;
 
-        // The todo list is a stack, and processed in reverse-order, append
+        // The work_stack is necessarily processed in reverse-order, so append
         // these commands to the end in reverse-order so the first command is
         // processed first (i.e. at the end of the list).
         if subcommand_name != "help" {
-            todo.extend(
+            work_stack.extend(
                 command
                     .get_subcommands_mut()
                     .filter(|cmd| !cmd.is_hide_set())
diff --git a/cargo-dist/src/tasks.rs b/cargo-dist/src/tasks.rs
index d0dda7fd..a7ec4051 100644
--- a/cargo-dist/src/tasks.rs
+++ b/cargo-dist/src/tasks.rs
@@ -66,8 +66,8 @@ use crate::config::DirtyMode;
 use crate::{
     backend::{
         installer::{
-            homebrew::HomebrewInstallerInfo, npm::NpmInstallerInfo, ExecutableZipFragment,
-            InstallerImpl, InstallerInfo,
+            homebrew::HomebrewInstallerInfo, msi::MsiInstallerInfo, npm::NpmInstallerInfo,
+            ExecutableZipFragment, InstallerImpl, InstallerInfo,
         },
         templates::Templates,
     },
@@ -250,6 +250,8 @@ pub struct Binary {
     pub pkg_spec: String,
     /// The name of the binary (as defined by the Cargo.toml)
     pub name: String,
+    /// The filename the binary will have
+    pub file_name: String,
     /// The target triple to build it for
     pub target: TargetTriple,
     /// The artifact for this Binary's symbols
@@ -263,6 +265,7 @@ pub struct Binary {
     pub copy_symbols_to: Vec<Utf8PathBuf>,
     /// feature flags!
     pub features: CargoTargetFeatures,
+    pkg_idx: PackageIdx,
 }
 
 /// A build step we would like to perform
@@ -862,28 +865,36 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
             let pkg_spec = package.name.clone();
             let id = format!("{binary_name}-v{version}-{target}");
 
-            let features = CargoTargetFeatures {
-                default_features: package_metadata.default_features.unwrap_or(true),
-                features: if let Some(true) = package_metadata.all_features {
-                    CargoTargetFeatureList::All
-                } else {
-                    CargoTargetFeatureList::List(
-                        package_metadata.features.clone().unwrap_or_default(),
-                    )
-                },
-            };
-
-            // If we already are building this binary we don't need to do it again!
             let idx = if let Some(&idx) = self.binaries_by_id.get(&id) {
+                // If we already are building this binary we don't need to do it again!
                 idx
             } else {
+                // Compute the rest of the details and add the binary
+                let features = CargoTargetFeatures {
+                    default_features: package_metadata.default_features.unwrap_or(true),
+                    features: if let Some(true) = package_metadata.all_features {
+                        CargoTargetFeatureList::All
+                    } else {
+                        CargoTargetFeatureList::List(
+                            package_metadata.features.clone().unwrap_or_default(),
+                        )
+                    },
+                };
+
+                let target_is_windows = target.contains("windows");
+                let platform_exe_ext = if target_is_windows { ".exe" } else { "" };
+
+                let file_name = format!("{binary_name}{platform_exe_ext}");
+
                 info!("added binary {id}");
                 let idx = BinaryIdx(self.inner.binaries.len());
                 let binary = Binary {
                     id,
                     pkg_id,
                     pkg_spec,
+                    pkg_idx,
                     name: binary_name,
+                    file_name,
                     target: target.clone(),
                     copy_exe_to: vec![],
                     copy_symbols_to: vec![],
@@ -992,7 +1003,6 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         } else {
             release.unix_archive
         };
-        let platform_exe_ext = if target_is_windows { ".exe" } else { "" };
 
         let artifact_dir_name = variant.id.clone();
         let artifact_dir_path = dist_dir.join(&artifact_dir_name);
@@ -1004,9 +1014,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         let mut built_assets = Vec::new();
         for &binary_idx in &variant.binaries {
             let binary = self.binary(binary_idx);
-            let exe_name = &binary.name;
-            let exe_filename = format!("{exe_name}{platform_exe_ext}");
-            built_assets.push((binary_idx, artifact_dir_path.join(exe_filename)));
+            built_assets.push((binary_idx, artifact_dir_path.join(&binary.file_name)));
         }
 
         // When unpacking we currently rely on zips being flat, but --strip-prefix=1 tarballs.
@@ -1039,6 +1047,17 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         )
     }
 
+    /// Register that `for_artifact` requires `binary_idx` to actually be built for
+    /// `for_variant`.
+    ///
+    /// `dest_path` is the file path to copy the binary to (used for Archives)
+    /// as soon as they're built.
+    ///
+    /// Note that it's important to use `dest_path`, as cargo does not guarantee that
+    /// multiple invocations will not overwrite each other's outputs. Since we always
+    /// explicitly pass --target and --profile, this is unlikely to be an issue. But if
+    /// we ever introduce the notion of "feature variants" (ReleaseVariants that differ
+    /// only in the feature flags they take), this will become a problem.
     fn require_binary(
         &mut self,
         for_artifact: ArtifactIdx,
@@ -1103,13 +1122,19 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
             .insert(binary_idx, dest_path);
     }
 
-    fn add_installer(&mut self, to_release: ReleaseIdx, installer: &InstallerStyle) {
+    fn add_installer(
+        &mut self,
+        to_release: ReleaseIdx,
+        installer: &InstallerStyle,
+    ) -> DistResult<()> {
         match installer {
             InstallerStyle::Shell => self.add_shell_installer(to_release),
             InstallerStyle::Powershell => self.add_powershell_installer(to_release),
             InstallerStyle::Npm => self.add_npm_installer(to_release),
             InstallerStyle::Homebrew => self.add_homebrew_installer(to_release),
+            InstallerStyle::Msi => self.add_msi_installer(to_release)?,
         }
+        Ok(())
     }
 
     fn add_shell_installer(&mut self, to_release: ReleaseIdx) {
@@ -1548,12 +1573,108 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
         self.add_global_artifact(to_release, installer_artifact);
     }
 
+    fn add_msi_installer(&mut self, to_release: ReleaseIdx) -> DistResult<()> {
+        if !self.local_artifacts_enabled() {
+            return Ok(());
+        }
+
+        // Clone info we need from the release to avoid borrowing across the loop
+        let release = self.release(to_release);
+        let variants = release.variants.clone();
+        let checksum = release.checksum;
+
+        // Make an msi for every windows platform
+        for variant_idx in variants {
+            let variant = self.variant(variant_idx);
+            let binaries = variant.binaries.clone();
+            let target = &variant.target;
+            if !target.contains("windows") {
+                continue;
+            }
+
+            let variant_id = &variant.id;
+            let artifact_name = format!("{variant_id}.msi");
+            let artifact_path = self.inner.dist_dir.join(&artifact_name);
+            let dir_name = format!("{variant_id}_msi");
+            let dir_path = self.inner.dist_dir.join(&dir_name);
+
+            // Compute which package we're actually building, based on the binaries
+            let mut package_info: Option<(String, PackageIdx)> = None;
+            for &binary_idx in &binaries {
+                let binary = self.binary(binary_idx);
+                if let Some((existing_spec, _)) = &package_info {
+                    // cargo-wix doesn't clearly support multi-package, so bail
+                    if existing_spec != &binary.pkg_spec {
+                        return Err(DistError::MultiPackageMsi {
+                            artifact_name,
+                            spec1: existing_spec.clone(),
+                            spec2: binary.pkg_spec.clone(),
+                        })?;
+                    }
+                } else {
+                    package_info = Some((binary.pkg_spec.clone(), binary.pkg_idx));
+                }
+            }
+            let Some((pkg_spec, pkg_idx)) = package_info else {
+                return Err(DistError::NoPackageMsi { artifact_name })?;
+            };
+            let manifest_path = self.workspace.package(pkg_idx).manifest_path.clone();
+            let wxs_path = manifest_path
+                .parent()
+                .expect("Cargo.toml had no parent dir!?")
+                .join("wix")
+                .join("main.wxs");
+
+            // Gather up the bundles the installer supports
+            let installer_artifact = Artifact {
+                id: artifact_name,
+                target_triples: vec![target.clone()],
+                file_path: artifact_path.clone(),
+                required_binaries: FastMap::new(),
+                archive: Some(Archive {
+                    with_root: None,
+                    dir_path: dir_path.clone(),
+                    zip_style: ZipStyle::TempDir,
+                    static_assets: vec![],
+                }),
+                checksum: None,
+                kind: ArtifactKind::Installer(InstallerImpl::Msi(MsiInstallerInfo {
+                    package_dir: dir_path.clone(),
+                    pkg_spec,
+                    target: target.clone(),
+                    file_path: artifact_path.clone(),
+                    wxs_path,
+                    manifest_path,
+                })),
+                is_global: false,
+            };
+
+            // Register the artifact to various things
+            let installer_idx = self.add_local_artifact(variant_idx, installer_artifact);
+            for binary_idx in binaries {
+                let binary = self.binary(binary_idx);
+                self.require_binary(
+                    installer_idx,
+                    variant_idx,
+                    binary_idx,
+                    dir_path.join(&binary.file_name),
+                );
+            }
+            if checksum != ChecksumStyle::False {
+                self.add_artifact_checksum(variant_idx, installer_idx, checksum);
+            }
+        }
+
+        Ok(())
+    }
+
     fn add_local_artifact(
         &mut self,
         to_variant: ReleaseVariantIdx,
         artifact: Artifact,
     ) -> ArtifactIdx {
         assert!(self.local_artifacts_enabled());
+        assert!(!artifact.is_global);
 
         let idx = ArtifactIdx(self.inner.artifacts.len());
         let ReleaseVariant {
@@ -1567,6 +1688,7 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
 
     fn add_global_artifact(&mut self, to_release: ReleaseIdx, artifact: Artifact) -> ArtifactIdx {
         assert!(self.global_artifacts_enabled());
+        assert!(artifact.is_global);
 
         let idx = ArtifactIdx(self.inner.artifacts.len());
         let Release {
@@ -1872,11 +1994,16 @@ impl<'pkg_graph> DistGraphBuilder<'pkg_graph> {
             if !global_installers.is_empty() {
                 writeln!(gh_body, "## Install {heading_suffix}\n").unwrap();
                 for (_installer, details) in global_installers {
-                    let (InstallerImpl::Shell(info)
-                    | InstallerImpl::Homebrew(HomebrewInstallerInfo { inner: info, .. })
-                    | InstallerImpl::Powershell(info)
-                    | InstallerImpl::Npm(NpmInstallerInfo { inner: info, .. })) = details;
-
+                    let info = match details {
+                        InstallerImpl::Shell(info)
+                        | InstallerImpl::Homebrew(HomebrewInstallerInfo { inner: info, .. })
+                        | InstallerImpl::Powershell(info)
+                        | InstallerImpl::Npm(NpmInstallerInfo { inner: info, .. }) => info,
+                        InstallerImpl::Msi(_) => {
+                            // Should be unreachable, but let's not crash over it
+                            continue;
+                        }
+                    };
                     writeln!(&mut gh_body, "### {}\n", info.desc).unwrap();
                     writeln!(&mut gh_body, "```sh\n{}\n```\n", info.hint).unwrap();
                 }
@@ -2133,7 +2260,7 @@ pub fn gather_work(cfg: &Config) -> Result<DistGraph> {
             }
 
             // Create the variant
-            graph.add_installer(release, installer);
+            graph.add_installer(release, installer)?;
         }
     }
 


Test patch:
diff --git a/cargo-dist/tests/gallery/dist.rs b/cargo-dist/tests/gallery/dist.rs
index 3a483252..c31862dd 100644
--- a/cargo-dist/tests/gallery/dist.rs
+++ b/cargo-dist/tests/gallery/dist.rs
@@ -27,7 +27,7 @@ pub static AXOLOTLSAY: TestContextLock<Tools> = TestContextLock::new(
     &Repo {
         repo_owner: "axodotdev",
         repo_name: "axolotlsay",
-        commit_sha: "6b8337fb742908e506296eab3371bb71b76283d7",
+        commit_sha: "b9e3554cd0db987494ab844d676a6fd30f861741",
         app_name: "axolotlsay",
         bins: &["axolotlsay"],
     },
@@ -104,9 +104,10 @@ pub struct PlanResult {
     raw_json: String,
 }
 
-pub struct GenerateCiResult {
+pub struct GenerateResult {
     test_name: String,
     github_ci_path: Option<Utf8PathBuf>,
+    wxs_path: Option<Utf8PathBuf>,
 }
 
 pub struct BuildAndPlanResult {
@@ -159,12 +160,16 @@ impl<'a> TestContext<'a, Tools> {
         self.load_dist_results(test_name)
     }
     /// Run 'cargo dist generate-ci' and return paths to various files that were generated
-    pub fn cargo_dist_generate(&self, test_name: &str) -> Result<GenerateCiResult> {
+    pub fn cargo_dist_generate(&self, test_name: &str) -> Result<GenerateResult> {
         let github_ci_path = Utf8Path::new(".github/workflows/release.yml").to_owned();
-        // Delete ci.yml if it already exists
+        let wxs_path = Utf8Path::new("wix/main.wxs").to_owned();
+        // Delete files if they already exist
         if github_ci_path.exists() {
             LocalAsset::remove_file(&github_ci_path)?;
         }
+        if wxs_path.exists() {
+            LocalAsset::remove_file(&wxs_path)?;
+        }
 
         // run generate-ci
         eprintln!("running cargo dist generate...");
@@ -172,9 +177,10 @@ impl<'a> TestContext<'a, Tools> {
             .cargo_dist
             .output_checked(|cmd| cmd.arg("dist").arg("generate"))?;
 
-        Ok(GenerateCiResult {
+        Ok(GenerateResult {
             test_name: test_name.to_owned(),
             github_ci_path: github_ci_path.exists().then_some(github_ci_path),
+            wxs_path: wxs_path.exists().then_some(wxs_path),
         })
     }
 
@@ -206,10 +212,20 @@ impl<'a> TestContext<'a, Tools> {
         let new_table = new_table_src.deserialize_toml_edit()?;
 
         // Written slightly verbosely to make it easier to isolate which failed
-        eprintln!("{new_table}");
-        let old = &mut toml["workspace"]["metadata"]["dist"];
-        let new = &new_table["workspace"]["metadata"]["dist"];
-        *old = new.clone();
+        let namespaces = ["workspace", "package"];
+        for namespace in namespaces {
+            let Some(new_meta) = new_table.get(namespace).and_then(|t| t.get("metadata")) else {
+                continue;
+            };
+            let old_namespace = toml[namespace].or_insert(toml_edit::table());
+            let old_meta = old_namespace["metadata"].or_insert(toml_edit::table());
+            eprintln!("{new_table}");
+            for (key, new) in new_meta.as_table().unwrap() {
+                let old = &mut old_meta[key];
+                *old = new.clone();
+            }
+        }
+
         let toml_out = toml.to_string();
         eprintln!("writing Cargo.toml...");
         axoasset::LocalAsset::write_new(&toml_out, "Cargo.toml")?;
@@ -503,7 +519,7 @@ impl BuildAndPlanResult {
     }
 }
 
-impl GenerateCiResult {
+impl GenerateResult {
     pub fn check_all(&self) -> Result<Snapshots> {
         self.snapshot()
     }
@@ -520,6 +536,8 @@ impl GenerateCiResult {
             self.github_ci_path.as_deref(),
         )?;
 
+        append_snapshot_file(&mut snapshots, "main.wxs", self.wxs_path.as_deref())?;
+
         Ok(Snapshots {
             settings: snapshot_settings_with_gallery_filter(),
             name: self.test_name.to_owned(),
diff --git a/cargo-dist/tests/integration-tests.rs b/cargo-dist/tests/integration-tests.rs
index e19cadbd..97461e2e 100644
--- a/cargo-dist/tests/integration-tests.rs
+++ b/cargo-dist/tests/integration-tests.rs
@@ -34,7 +34,7 @@ fn axolotlsay_basic() -> Result<(), miette::Report> {
         ctx.patch_cargo_toml(format!(r#"
 [workspace.metadata.dist]
 cargo-dist-version = "{dist_version}"
-installers = ["shell", "powershell", "homebrew", "npm"]
+installers = ["shell", "powershell", "homebrew", "npm", "msi"]
 tap = "axodotdev/homebrew-packages"
 publish-jobs = ["homebrew"]
 targets = ["x86_64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-apple-darwin"]
@@ -43,6 +43,10 @@ unix-archive = ".tar.gz"
 windows-archive = ".tar.gz"
 scope = "@axodotdev"
 
+[package.metadata.wix]
+upgrade-guid = "B36177BE-EA4D-44FB-B05C-EDDABDAA95CA"
+path-guid = "BFD25009-65A4-4D1E-97F1-0030465D90D6"
+
 "#
         ))?;
 
diff --git a/cargo-dist/tests/snapshots/axolotlsay_basic.snap b/cargo-dist/tests/snapshots/axolotlsay_basic.snap
index 1da8d988..f4536723 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_basic.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_basic.snap
@@ -1781,6 +1781,7 @@ install(false);
   "description": "💬 a CLI for learning to distribute CLIs in rust",
   "repository": "https://github.com/axodotdev/axolotlsay",
   "license": "MIT OR Apache-2.0",
+  "author": "axo.dev",
   "bin": {
     "axolotlsay": "run.js"
   },
@@ -1821,7 +1822,7 @@ maybeInstall(true).then(run);
   "announcement_is_prerelease": false,
   "announcement_title": "Version 0.1.0",
   "announcement_changelog": "```text\n         +------------------------+\n         | the initial release!!! |\n         +------------------------+\n        /\n≽(◕ ᴗ ◕)≼\n```",
-  "announcement_github_body": "## Release Notes\n\n```text\n         +------------------------+\n         | the initial release!!! |\n         +------------------------+\n        /\n≽(◕ ᴗ ◕)≼\n```\n\n## Install axolotlsay 0.1.0\n\n### Install prebuilt binaries via shell script\n\n```sh\ncurl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-installer.sh | sh\n```\n\n### Install prebuilt binaries via powershell script\n\n```sh\nirm https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-installer.ps1 | iex\n```\n\n### Install prebuilt binaries via Homebrew\n\n```sh\nbrew install axodotdev/homebrew-packages/axolotlsay\n```\n\n### Install prebuilt binaries into your npm project\n\n```sh\nnpm install axolotlsay@0.1.0\n```\n\n## Download axolotlsay 0.1.0\n\n|  File  | Platform | Checksum |\n|--------|----------|----------|\n| [axolotlsay-aarch64-apple-darwin.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-aarch64-apple-darwin.tar.gz) | macOS Apple Silicon | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-aarch64-apple-darwin.tar.gz.sha256) |\n| [axolotlsay-x86_64-apple-darwin.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-apple-darwin.tar.gz) | macOS Intel | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-apple-darwin.tar.gz.sha256) |\n| [axolotlsay-x86_64-pc-windows-msvc.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-pc-windows-msvc.tar.gz) | Windows x64 | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-pc-windows-msvc.tar.gz.sha256) |\n| [axolotlsay-x86_64-unknown-linux-gnu.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-unknown-linux-gnu.tar.gz) | Linux x64 | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-unknown-linux-gnu.tar.gz.sha256) |\n\n",
+  "announcement_github_body": "## Release Notes\n\n```text\n         +------------------------+\n         | the initial release!!! |\n         +------------------------+\n        /\n≽(◕ ᴗ ◕)≼\n```\n\n## Install axolotlsay 0.1.0\n\n### Install prebuilt binaries via shell script\n\n```sh\ncurl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-installer.sh | sh\n```\n\n### Install prebuilt binaries via powershell script\n\n```sh\nirm https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-installer.ps1 | iex\n```\n\n### Install prebuilt binaries via Homebrew\n\n```sh\nbrew install axodotdev/homebrew-packages/axolotlsay\n```\n\n### Install prebuilt binaries into your npm project\n\n```sh\nnpm install axolotlsay@0.1.0\n```\n\n## Download axolotlsay 0.1.0\n\n|  File  | Platform | Checksum |\n|--------|----------|----------|\n| [axolotlsay-aarch64-apple-darwin.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-aarch64-apple-darwin.tar.gz) | macOS Apple Silicon | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-aarch64-apple-darwin.tar.gz.sha256) |\n| [axolotlsay-x86_64-apple-darwin.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-apple-darwin.tar.gz) | macOS Intel | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-apple-darwin.tar.gz.sha256) |\n| [axolotlsay-x86_64-pc-windows-msvc.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-pc-windows-msvc.tar.gz) | Windows x64 | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-pc-windows-msvc.tar.gz.sha256) |\n| [axolotlsay-x86_64-unknown-linux-gnu.tar.gz](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-unknown-linux-gnu.tar.gz) | Linux x64 | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-unknown-linux-gnu.tar.gz.sha256) |\n| [axolotlsay-x86_64-pc-windows-msvc.msi](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-pc-windows-msvc.msi) | Windows x64 | [checksum](https://github.com/axodotdev/axolotlsay/releases/download/v0.1.0/axolotlsay-x86_64-pc-windows-msvc.msi.sha256) |\n\n",
   "system_info": {
     "cargo_version_line": "CENSORED"
   },
@@ -1840,6 +1841,8 @@ maybeInstall(true).then(run);
         "axolotlsay-x86_64-apple-darwin.tar.gz.sha256",
         "axolotlsay-x86_64-pc-windows-msvc.tar.gz",
         "axolotlsay-x86_64-pc-windows-msvc.tar.gz.sha256",
+        "axolotlsay-x86_64-pc-windows-msvc.msi",
+        "axolotlsay-x86_64-pc-windows-msvc.msi.sha256",
         "axolotlsay-x86_64-unknown-linux-gnu.tar.gz",
         "axolotlsay-x86_64-unknown-linux-gnu.tar.gz.sha256"
       ]
@@ -2014,6 +2017,29 @@ maybeInstall(true).then(run);
         "x86_64-apple-darwin"
       ]
     },
+    "axolotlsay-x86_64-pc-windows-msvc.msi": {
+      "name": "axolotlsay-x86_64-pc-windows-msvc.msi",
+      "kind": "installer",
+      "target_triples": [
+        "x86_64-pc-windows-msvc"
+      ],
+      "assets": [
+        {
+          "name": "axolotlsay",
+          "path": "axolotlsay.exe",
+          "kind": "executable"
+        }
+      ],
+      "description": "install via msi",
+      "checksum": "axolotlsay-x86_64-pc-windows-msvc.msi.sha256"
+    },
+    "axolotlsay-x86_64-pc-windows-msvc.msi.sha256": {
+      "name": "axolotlsay-x86_64-pc-windows-msvc.msi.sha256",
+      "kind": "checksum",
+      "target_triples": [
+        "x86_64-pc-windows-msvc"
+      ]
+    },
     "axolotlsay-x86_64-pc-windows-msvc.tar.gz": {
       "name": "axolotlsay-x86_64-pc-windows-msvc.tar.gz",
       "kind": "executable-zip",
@@ -2367,4 +2393,234 @@ jobs:
           prerelease: ${{ fromJson(needs.plan.outputs.val).announcement_is_prerelease }}
           artifacts: "artifacts/*"
 
+================ main.wxs ================
+<?xml version='1.0' encoding='windows-1252'?>
+<!--
+  Copyright (C) 2017 Christopher R. Field.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
+<!--
+  The "cargo wix" subcommand provides a variety of predefined variables available
+  for customization of this template. The values for each variable are set at
+  installer creation time. The following variables are available:
+
+  TargetTriple      = The rustc target triple name.
+  TargetEnv         = The rustc target environment. This is typically either
+                      "msvc" or "gnu" depending on the toolchain downloaded and
+                      installed.
+  TargetVendor      = The rustc target vendor. This is typically "pc", but Rust
+                      does support other vendors, like "uwp".
+  CargoTargetBinDir = The complete path to the directory containing the
+                      binaries (exes) to include. The default would be
+                      "target\release\". If an explicit rustc target triple is
+                      used, i.e. cross-compiling, then the default path would
+                      be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
+                      where "<CARGO_TARGET>" is replaced with the "CargoTarget"
+                      variable value and "<CARGO_PROFILE>" is replaced with the
+                      value from the "CargoProfile" variable. This can also
+                      be overriden manually with tne "target-bin-dir" flag.
+  CargoTargetDir    = The path to the directory for the build artifacts, i.e.
+                      "target".
+  CargoProfile      = The cargo profile used to build the binaries
+                      (usually "debug" or "release").
+  Version           = The version for the installer. The default is the
+                      "Major.Minor.Fix" semantic versioning number of the Rust
+                      package.
+-->
+
+<!--
+  Please do not remove these pre-processor If-Else blocks. These are used with
+  the `cargo wix` subcommand to automatically determine the installation
+  destination for 32-bit versus 64-bit installers. Removal of these lines will
+  cause installation errors.
+-->
+<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
+    <?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
+<?else ?>
+    <?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
+<?endif ?>
+
+<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
+
+    <Product
+        Id='*'
+        Name='axolotlsay'
+        UpgradeCode='B36177BE-EA4D-44FB-B05C-EDDABDAA95CA'
+        Manufacturer='axo.dev'
+        Language='1033'
+        Codepage='1252'
+        Version='$(var.Version)'>
+
+        <Package Id='*'
+            Keywords='Installer'
+            Description='💬 a CLI for learning to distribute CLIs in rust'
+            Manufacturer='axo.dev'
+            InstallerVersion='450'
+            Languages='1033'
+            Compressed='yes'
+            InstallScope='perMachine'
+            SummaryCodepage='1252'
+            />
+
+        <MajorUpgrade
+            Schedule='afterInstallInitialize'
+            DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
+
+        <Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
+        <Property Id='DiskPrompt' Value='axolotlsay Installation'/>
+
+        <Directory Id='TARGETDIR' Name='SourceDir'>
+            <Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
+                <Directory Id='APPLICATIONFOLDER' Name='axolotlsay'>
+                    
+                    <!--
+                      Enabling the license sidecar file in the installer is a four step process:
+
+                      1. Uncomment the `Component` tag and its contents.
+                      2. Change the value for the `Source` attribute in the `File` tag to a path
+                         to the file that should be included as the license sidecar file. The path
+                         can, and probably should be, relative to this file.
+                      3. Change the value for the `Name` attribute in the `File` tag to the
+                         desired name for the file when it is installed alongside the `bin` folder
+                         in the installation directory. This can be omitted if the desired name is
+                         the same as the file name.
+                      4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
+                         further down in this file.
+                    -->
+                    <!--
+                    <Component Id='License' Guid='*'>
+                        <File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
+                    </Component>
+                    -->
+
+                    <Directory Id='Bin' Name='bin'>
+                        <Component Id='Path' Guid='BFD25009-65A4-4D1E-97F1-0030465D90D6' KeyPath='yes'>
+                            <Environment
+                                Id='PATH'
+                                Name='PATH'
+                                Value='[Bin]'
+                                Permanent='no'
+                                Part='last'
+                                Action='set'
+                                System='yes'/>
+                        </Component>
+                        <Component Id='binary0' Guid='*'>
+                            <File
+                                Id='exe0'
+                                Name='axolotlsay.exe'
+                                DiskId='1'
+                                Source='$(var.CargoTargetBinDir)\axolotlsay.exe'
+                                KeyPath='yes'/>
+                        </Component>
+                    </Directory>
+                </Directory>
+            </Directory>
+        </Directory>
+
+        <Feature
+            Id='Binaries'
+            Title='Application'
+            Description='Installs all binaries and the license.'
+            Level='1'
+            ConfigurableDirectory='APPLICATIONFOLDER'
+            AllowAdvertise='no'
+            Display='expand'
+            Absent='disallow'>
+            
+            <!--
+              Uncomment the following `ComponentRef` tag to add the license
+              sidecar file to the installer.
+            -->
+            <!--<ComponentRef Id='License'/>-->
+
+            <ComponentRef Id='binary0'/>
+
+            <Feature
+                Id='Environment'
+                Title='PATH Environment Variable'
+                Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
+                Level='1'
+                Absent='allow'>
+                <ComponentRef Id='Path'/>
+            </Feature>
+        </Feature>
+
+        <SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
+
+        
+        <!--
+          Uncomment the following `Icon` and `Property` tags to change the product icon.
+
+          The product icon is the graphic that appears in the Add/Remove
+          Programs control panel for the application.
+        -->
+        <!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
+        <!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
+
+        <Property Id='ARPHELPLINK' Value='https://github.com/axodotdev/axolotlsay'/>
+        
+        <UI>
+            <UIRef Id='WixUI_FeatureTree'/>
+            
+            <!--
+              Enabling the EULA dialog in the installer is a three step process:
+
+                1. Comment out or remove the two `Publish` tags that follow the
+                   `WixVariable` tag.
+                2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag futher down
+                3. Replace the `Value` attribute of the `WixVariable` tag with
+                   the path to a RTF file that will be used as the EULA and
+                   displayed in the license agreement dialog.
+            -->
+            <Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
+            <Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
+
+        </UI>
+
+        
+        <!--
+          Enabling the EULA dialog in the installer requires uncommenting
+          the following `WixUILicenseRTF` tag and changing the `Value`
+          attribute.
+        -->
+        <!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
+
+        
+        <!--
+          Uncomment the next `WixVariable` tag to customize the installer's
+          Graphical User Interface (GUI) and add a custom banner image across
+          the top of each screen. See the WiX Toolset documentation for details
+          about customization.
+
+          The banner BMP dimensions are 493 x 58 pixels.
+        -->
+        <!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
+
+        
+        <!--
+          Uncomment the next `WixVariable` tag to customize the installer's
+          Graphical User Interface (GUI) and add a custom image to the first
+          dialog, or screen. See the WiX Toolset documentation for details about
+          customization.
+
+          The dialog BMP dimensions are 493 x 312 pixels.
+        -->
+        <!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
+
+    </Product>
+
+</Wix>
+
 
diff --git a/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap b/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
index a0f3af56..31647c49 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_edit_existing.snap
@@ -1781,6 +1781,7 @@ install(false);
   "description": "💬 a CLI for learning to distribute CLIs in rust",
   "repository": "https://github.com/axodotdev/axolotlsay",
   "license": "MIT OR Apache-2.0",
+  "author": "axo.dev",
   "bin": {
     "axolotlsay": "run.js"
   },
diff --git a/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap b/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
index d86d4008..b5df6e90 100644
--- a/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
+++ b/cargo-dist/tests/snapshots/axolotlsay_no_homebrew_publish.snap
@@ -1781,6 +1781,7 @@ install(false);
   "description": "💬 a CLI for learning to distribute CLIs in rust",
   "repository": "https://github.com/axodotdev/axolotlsay",
   "license": "MIT OR Apache-2.0",
+  "author": "axo.dev",
   "bin": {
     "axolotlsay": "run.js"
   },
diff --git a/cargo-dist/tests/snapshots/long-help.snap b/cargo-dist/tests/snapshots/long-help.snap
index 0e9df482..444e4bcd 100644
--- a/cargo-dist/tests/snapshots/long-help.snap
+++ b/cargo-dist/tests/snapshots/long-help.snap
@@ -61,6 +61,7 @@ GLOBAL OPTIONS:
           - powershell: Generates a powershell script that fetches/installs the right build
           - npm:        Generates an npm project that fetches the right build to your node_modules
           - homebrew:   Generates a Homebrew formula
+          - msi:        Generates an msi for each windows platform
 
   -c, --ci <CI>
           CI we want to support
diff --git a/cargo-dist/tests/snapshots/markdown-help.snap b/cargo-dist/tests/snapshots/markdown-help.snap
index 1def9c4e..b4137d02 100644
--- a/cargo-dist/tests/snapshots/markdown-help.snap
+++ b/cargo-dist/tests/snapshots/markdown-help.snap
@@ -63,6 +63,7 @@ Possible values:
 - powershell: Generates a powershell script that fetches/installs the right build
 - npm:        Generates an npm project that fetches the right build to your node_modules
 - homebrew:   Generates a Homebrew formula
+- msi:        Generates an msi for each windows platform
 
 #### `-c, --ci <CI>`
 CI we want to support
@@ -106,7 +107,7 @@ cargo dist build [OPTIONS]
 #### `-a, --artifacts <ARTIFACTS>`
 Which subset of the Artifacts to build
 
-Artifacts can be broken up into two major classes: "local" ones, which are made for each target system (executable-zips, symbols, MSIs...); and "global" ones, which are made once per app (curl-sh installers, npm package, metadata...).
+Artifacts can be broken up into two major classes: "local" ones, which are made for each target system (executable-zips, symbols, msi installers...); and "global" ones, which are made once per app (curl-sh installers, npm package, metadata...).
 
 Having this distinction lets us run cargo-dist independently on multiple machines without collisions between the outputs.
 
@@ -117,7 +118,7 @@ The specifics of "host" mode are intentionally unspecified to enable us to provi
 \[default: host]  
 
 Possible values:
-- local:  Build target-specific artifacts like executable-zips and MSIs
+- local:  Build target-specific artifacts like executable-zips and msi installers
 - global: Build unique artifacts like curl-sh installers and npm packages
 - host:   Fuzzily build "as much as possible" for the host system
 - all:    Build all the artifacts; useful for `cargo dist manifest`
@@ -175,7 +176,8 @@ cargo dist generate [OPTIONS] [MODE]...
 Which type of configuration to generate
 
 Possible values:
-- ci: Generate CI scripts for orchestrating cargo-dist
+- ci:  Generate CI scripts for orchestrating cargo-dist
+- msi: Generate .wxs tempaltes for msi installers
 
 ### Options
 #### `--check`
@@ -227,7 +229,7 @@ cargo dist manifest [OPTIONS]
 #### `-a, --artifacts <ARTIFACTS>`
 Which subset of the Artifacts to build
 
-Artifacts can be broken up into two major classes: "local" ones, which are made for each target system (executable-zips, symbols, MSIs...); and "global" ones, which are made once per app (curl-sh installers, npm package, metadata...).
+Artifacts can be broken up into two major classes: "local" ones, which are made for each target system (executable-zips, symbols, msi installers...); and "global" ones, which are made once per app (curl-sh installers, npm package, metadata...).
 
 Having this distinction lets us run cargo-dist independently on multiple machines without collisions between the outputs.
 
@@ -238,7 +240,7 @@ The specifics of "host" mode are intentionally unspecified to enable us to provi
 \[default: host]  
 
 Possible values:
-- local:  Build target-specific artifacts like executable-zips and MSIs
+- local:  Build target-specific artifacts like executable-zips and msi installers
 - global: Build unique artifacts like curl-sh installers and npm packages
 - host:   Fuzzily build "as much as possible" for the host system
 - all:    Build all the artifacts; useful for `cargo dist manifest`
diff --git a/cargo-dist/tests/snapshots/short-help.snap b/cargo-dist/tests/snapshots/short-help.snap
index 162cbd61..2525f497 100644
--- a/cargo-dist/tests/snapshots/short-help.snap
+++ b/cargo-dist/tests/snapshots/short-help.snap
@@ -26,7 +26,7 @@ GLOBAL OPTIONS:
   -o, --output-format <OUTPUT_FORMAT>  The format of the output [default: human] [possible values: human, json]
       --no-local-paths                 Strip local paths from output (e.g. in the dist manifest json)
   -t, --target <TARGET>                Target triples we want to build
-  -i, --installer <INSTALLER>          Installers we want to build [possible values: shell, powershell, npm, homebrew]
+  -i, --installer <INSTALLER>          Installers we want to build [possible values: shell, powershell, npm, homebrew, msi]
   -c, --ci <CI>                        CI we want to support [possible values: github]
       --tag <TAG>                      The (git) tag to use for the Announcement that each invocation of cargo-dist is performing
       --allow-dirty                    Allow generated files like CI scripts to be out of date