Skip to content

feat: add msi support - #1097

Open
djgilcrease wants to merge 3 commits into
mainfrom
msi-builder
Open

djgilcrease wants to merge 3 commits into
mainfrom
msi-builder

Conversation

@djgilcrease

@djgilcrease djgilcrease commented Jun 13, 2026 •

Copy link
Copy Markdown
Contributor

Adds an msi packager that produces a real Windows Installer database with the pure-Go go-msi library (no WiX, no Windows needed to build or sign).

What you get

  • Contents mapped onto the standard Windows folders (/Program Files, /ProgramData, /Windows/System32, ...); everything else lands under the product's own folder in Program Files (or %LOCALAPPDATA%\Programs for per_user installs).
  • Root metadata reused where MSI has a place for it: description/homepage become the Add/Remove Programs fields, vendor/maintainer the manufacturer, type: license contents the install-wizard license text, and the root scripts become elevated deferred custom actions with deb/rpm hook semantics.
  • Under msi:: advertised shortcuts, Windows services, registry values, per-user installs, a canned minimal UI, and Authenticode signing with a PFX (plus RFC 3161 timestamps).
  • Every release replaces older installs of the same upgrade code in place, and a downgrade is refused (allow_downgrades / allow_same_version are available). The upgrade code and component GUIDs are derived from identities that survive releases; the product code follows the full version.

Verification

  • Unit tests build real packages, run go-msi's ICE validator, and assert on the decoded tables (go-msi v0.5.0 exports a read-only reader for this). Golden GUIDs pin the derivation contract.
  • TestMSIStructure (Linux) builds amd64/arm64 packages from a real config.
  • On a Windows runner: install, run the app, check the service/registry/shortcut, upgrade to the next version in place (exactly one product registered), uninstall (everything gone), and prove a failing postinstall rolls the install back. A second job validates and installs a package that nfpm signed on Linux.

Commits

  1. chore(deps): go-msi v0.5.0, go-pkcs12 v0.7.3
  2. feat: add msi support
  3. ci: the signed-package validation job

The MSIX behavior changes that used to live here are now #1135.

@pull-request-size pull-request-size Bot added the size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. label Jun 13, 2026
@codecov

codecov Bot commented Jun 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.00399% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.99%. Comparing base (bda9b3f) to head (2048745).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
msi/msi.go 97.98% 8 Missing ⚠️
internal/cmd/root.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1097      +/-   ##
==========================================
+ Coverage   83.74%   85.99%   +2.24%     
==========================================
  Files          30       33       +3     
  Lines        2763     3257     +494     
==========================================
+ Hits         2314     2801     +487     
- Misses        449      456       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 13, 2026 •

Copy link
Copy Markdown

Deploying nfpm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2048745
Status: ✅  Deploy successful!
Preview URL: https://a4bb9d23.nfpm.pages.dev
Branch Preview URL: https://msi-builder.nfpm.pages.dev

View logs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new msi packager to nfpm, enabling generation of Windows Installer .msi packages (and associated config/schema/docs) using the pure-Go go-msi library, plus CI/acceptance coverage for MSI structure and Windows install verification.

Changes:

  • Introduces a new msi packager implementation and unit tests (msi/).
  • Extends nfpm configuration (nfpm.go), schema (www/static/schema.json), and docs (www/content/docs/configuration.md) with MSI-specific fields.
  • Adds MSI acceptance test configs/scripts and wires them into Taskfile and GitHub Actions.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
www/static/schema.json Adds JSON schema definitions and references for the new msi config block.
www/content/docs/configuration.md Documents MSI-specific configuration and conventions (Windows destinations, services, registry, signing).
testdata/acceptance/msi.install.yaml Adds Windows install-test MSI config used by Taskfile/CI.
testdata/acceptance/msi.basic.yaml Adds MSI acceptance config used by TestMSIStructure.
testdata/acceptance/install-msi.ps1 Adds PowerShell script to install/uninstall MSI and verify installed binary.
Taskfile.yml Adds Windows MSI packaging/install tasks for acceptance coverage.
nfpm.go Adds MSI config structs and env-var expansion for MSI signing/manufacturer.
msi/msi.go Implements the MSI packager (contents mapping, shortcuts, services, registry, signing).
msi/msi_test.go Adds unit tests validating MSI structure, ICE checks, defaults, GUID derivation, and signing.
internal/cmd/root.go Registers the MSI packager in the CLI via blank import.
go.mod Adds dependencies for MSI generation and PKCS#12 support.
go.sum Records checksums for newly added MSI-related dependencies.
files/files.go Fixes an infinite loop when walking parent dirs for Windows drive-letter destinations.
files/files_test.go Adds regression test for drive-letter destination termination.
acceptance_test.go Adds an MSI acceptance test validating MSI CFB header and ICE findings.
.github/workflows/build.yml Adds CI jobs for MSI structure validation (Linux) and install verification (Windows).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread msi/msi.go
Comment thread Taskfile.yml
Comment thread .github/workflows/build.yml Outdated
Comment thread .github/workflows/build.yml Outdated
@djgilcrease

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

Copilot AI commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved in 8aa93c2.

@djgilcrease

Copy link
Copy Markdown
Contributor Author

@caarlos0 feedback / thoughts?

@caarlos0

Copy link
Copy Markdown
Member

@djgilcrease sorry was short on time this past few weeks, gonna review this now :D

@caarlos0 caarlos0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one small thing

other than that, i almost feel like the windows packaging should live in a different project, as nfpm was mostly thought out to handle Linux only... e.g. it doesn't reuse most of the fields from the root structure, and mainly use the its own fields. Same for msix that is already merged.

Do you have any opinions on this? maybe a wpm project (windows package manager) or something like that

Comment thread www/go.mod
@djgilcrease

Copy link
Copy Markdown
Contributor Author

one small thing

other than that, i almost feel like the windows packaging should live in a different project, as nfpm was mostly thought out to handle Linux only... e.g. it doesn't reuse most of the fields from the root structure, and mainly use the its own fields. Same for msix that is already merged.

Do you have any opinions on this? maybe a wpm project (windows package manager) or something like that

Let me see if I can refactor them to be more shared, if not, I can see splitting it to a wpm, that would focus on msi/msix/nuget/winget/etc

@djgilcrease

Copy link
Copy Markdown
Contributor Author

Ok, I cleaned it up a bit to default to using the existing fields, including the license type for content instead of a special msi.license field.

If you still feel this should be split, I am fine with that and will work on creating that.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (4)

internal/cmd/root.go:13

  • The new packager is registered, but the root command's Short and Long descriptions and the version app-details string still list MSIX without MSI. Consequently nfpm --help and version metadata continue to present an incomplete supported-format list. Update those user-facing strings when registering the new format.
	_ "github.com/goreleaser/nfpm/v2/msi"  // msi packager

msi/msi.go:372

  • For a 64-bit package this marks every component as 64-bit, including destinations explicitly mapped to the 32-bit ProgramFilesFolder (Program Files (x86)) or SystemFolder (SysWOW64). That is an ICE80 architecture mismatch. Suppress the 64-bit component attribute for these 32-bit roots.
		compID := makeID("c", dest)
		comp := b.Directory(parentID).Component(compID).WithGUID("")
		if attrs := componentAttributes(rootID, is64bit(info.Arch)); attrs != 0 {
			comp = comp.WithAttributes(attrs)
		}

msi/msi.go:84

  • The PR description says this adds both MSI and MSP generation, but this packager always advertises .msi and there is no patch input/output path or MSP configuration anywhere in the change. Either implement the promised MSP generation flow or narrow the PR description to MSI support.
// ConventionalExtension returns the file extension for MSI packages.
func (*MSI) ConventionalExtension() string {
	return ".msi"

msi/msi.go:255

  • These destination maps are case-sensitive even though normal Windows/MSI install paths are case-insensitive. A shortcut or service target that differs only in case is rejected, while two content destinations differing only in case are accepted and authored onto the same filesystem path. Use a lower-cased canonical destination key for duplicate detection and for the dests/placed lookups while preserving the original path for emitted names.
	dests := map[string]bool{}
	for _, c := range info.Contents {
		if c.Type == files.TypeDir || c.Type == files.TypeImplicitDir || c.Type == files.TypeSymlink {
			continue
		}
		dests[normalizeDest(c.Destination)] = true
	}
  • Files reviewed: 26/29 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread msi/msi.go
Comment thread msi/msi.go
Comment thread msi/msi.go Outdated
Comment thread msi/msi.go Outdated
@djgilcrease

Copy link
Copy Markdown
Contributor Author

Added signing validation based on the fix in digitalxero/go-msix#4

@caarlos0 caarlos0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by GitHub Copilot CLI (a bot), acting for @caarlos0. Two independent checks (maintainer + adversarial) were run and their claims re-verified against the go-msi@v0.4.0 source.

This is good work — a real Windows Installer database, ICE validation in the unit tests, and a genuine msiexec install/uninstall acceptance job on a Windows runner. That last part is more verification than most packagers here shipped with. The blocker below is a default-path data-loss bug, not a design objection.

Verdict

One blocking finding (inline, msi/msi.go:416): with the default configuration, every release gets new component GUIDs and no Upgrade table, so installing v1.1.0 over v1.0.0 leaves two products, and uninstalling v1.0.0 deletes the files v1.1.0 needs. Both independent checks found this separately; I confirmed the mechanism in go-msi/compile.go:333.

One security finding (inline, msi/scripts.go:98) and nine smaller items follow inline.

Cross-cutting: scope

The MSIX changes do not belong in a commit titled feat: add msi support. nfpm.go:289 makes $NFPM_PASSPHRASE apply to MSIX signing for the first time, msix/msix.go:174-186 makes msix.publisher optional, and schema.json drops publisher from the MSIX required set. Each change looks correct and is tested — but they are user-visible changes to a shipped packager. Please split them into their own PR so they can be released and reverted independently. The chore(deps) bumps belong in a third commit.

Cross-cutting: test coverage

packageAndValidate building a real MSI and running the full ICE validator is the right shape, and coverage_test.go is genuine table-driven unit testing despite the name (please rename it or merge it into msi_test.go — the name suggests coverage padding, which it is not).

The gap is that most feature tests assert only "builds and is ICE-clean". TestShortcut, TestService, TestRegistry, TestMajorUpgrade, and TestPackageSystemDir would all still pass if the corresponding add* function became a no-op. These contracts have no assertion at all:

  • component GUID stability across versions (the blocking finding above)
  • custom action scheduling: anchors, before/after, per-hook conditions
  • install_dir reaching INSTALLFOLDER's DefaultDir
  • all_users: false
  • minimal_ui
  • the upgrade.enabled: false default
  • the accept side of maxScriptSize

go-msi exports no table reader, so bytes.Contains on the raw CFB (already used by TestManufacturerFallback) is the pragmatic assertion here — it is still far better than asserting nothing.

The acceptance script (install-msi.ps1) covers install through uninstall well, but only the happy path. The docs claim "a non-zero exit rolls back the transaction (including on uninstall)"; nothing tests it. A v1 to v2 upgrade step would also have caught the blocking finding.

Verification gaps to disclose

Per instruction I did not build or run tests; nothing here comes from an executed test. Reasoning is from reading this diff and the go-msi@v0.4.0 source over the GitHub API (the module cache was not writable in this sandbox). Specifically unverified: the runtime behavior of a CustomAction.Target longer than the declared 255-char schema width on Windows versions other than the CI runner, and the exact failure mode of the destination collision noted at msi/msi.go:605.

Comment thread msi/msi.go Outdated
Comment thread msi/scripts.go Outdated
Comment thread msi/msi.go
Comment thread msi/msi.go
Comment thread nfpm.go Outdated
Comment thread msi/msi.go
Comment thread msi/scripts.go
Comment thread msi/msi.go Outdated
Comment thread msix/msix.go Outdated
Comment thread testdata/acceptance/install-msi.ps1 Outdated
go.digitalxero.dev/go-msi is the pure-Go Windows Installer database
writer, validator, reader and Authenticode signer behind the new msi
packager; go-pkcs12 becomes a direct dependency for the signing tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Hnro326ZTAu2EqUTGGkCB
@djgilcrease

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Everything above is addressed (replies inline), the MSIX changes are split out to #1135, and the branch is rebased into the three commits you asked for (deps / feat / ci).

While going through it I also ran a full pass over the implementation and fixed a few things the review did not raise. The root-cause ones are in go-msi v0.5.0, which this PR now pins:

  • Registry components carried a KeyPath without the RegistryKeyPath attribute, so Windows Installer would have looked the value up in the File table. go-msi now sets the bit for AsKeyPath registry values, nfpm sets it explicitly too, and go-msi's ICE18/ICE92 read the right column (they were reading Condition), so the validator would actually have caught it.
  • ICE80 now also flags a 64-bit component under a 32-bit-only folder; nfpm derives the 64-bit bit from the folder, so /Program Files (x86) and /Windows/SysWOW64 content in an x64 package is 32-bit.
  • Sibling directories with a common 6-character prefix got the same 8.3 short name; Shortcut.Name was emitted without the short|long form.
  • go-msi exports a read-only table reader (msi.Open), which is what the tests now assert against.

nfpm-side, beyond the inline items:

  • The install folder now lives under Program Files (per-machine) or %LOCALAPPDATA%\Programs (per-user) instead of the drive root, so Linux-style destinations like /usr/bin/app end up in a sane place. A per-user package is validated against Program Files / Windows / HKLM / services.
  • A version that does not fit 255.255.65535 is an error instead of a clamp: two calendar versions clamped to the same value produced byte-identical packages with the same ProductCode. The product code is also seeded from the full version, so a pre-release and its release get distinct codes.
  • Services are always stopped before their files are touched and stopped+deleted on uninstall (the stop knob is gone); the default left an orphaned service registration.
  • Shortcut directory is validated against the standard folders; identifier hash collisions fail the build instead of merging two files; msi.properties values and the other free-form MSI strings are env-expanded like deb.fields.
  • Docs cover install location, permanent system files, per-user rules, the version rule, the pre-release case, service semantics, the failing-postremove and Group Policy execution-policy caveats, and arm being Windows RT only.

CI: the Windows job now installs, upgrades in place, uninstalls, and proves a failing postinstall rolls back; the signed job installs a package signed on Linux.

djgilcrease and others added 2 commits September 9, 2026 11:08
Adds an `msi` packager that writes a real Windows Installer database with
the pure-Go go-msi library: contents are mapped onto the standard Windows
folders (everything else under the product's Program Files folder), root
metadata (description, homepage, license, vendor/maintainer, scripts) is
reused where MSI has a place for it, and advertised shortcuts, Windows
services, registry values, per-user installs and Authenticode signing are
exposed under `msi:`.

Every release replaces older installs of the same upgrade code in place:
the upgrade code and component GUIDs are derived from identities that
survive releases, while the product code follows the full version. A
version Windows Installer cannot represent is an error rather than a
silently clamped (and colliding) one.

Maintainer scripts run as elevated deferred custom actions embedded in the
package so they work before any file is installed and after every file is
removed; they are extracted to a randomly named, freshly created file
since the SYSTEM temp directory is world-writable.

Unit tests build real packages, run go-msi's ICE validator and assert on
the decoded tables; golden GUIDs pin the derivation contract. The
acceptance suite installs, exercises, upgrades and removes the package
with msiexec on a Windows runner and proves a failing script rolls back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Hnro326ZTAu2EqUTGGkCB
Build and Authenticode-sign the MSI on Linux with nfpm's pure-Go signer
and a throwaway openssl certificate, then validate the signature with the
real Windows trust stack (Get-AuthenticodeSignature, signtool verify) and
install, exercise and remove it with msiexec on a Windows runner. The
unsigned msi-windows-install job remains as the control.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Hnro326ZTAu2EqUTGGkCB

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants