feat: add msi support - #1097
djgilcrease wants to merge 3 commits into
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
Deploying nfpm with
|
| Latest commit: |
2048745
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a4bb9d23.nfpm.pages.dev |
| Branch Preview URL: | https://msi-builder.nfpm.pages.dev |
There was a problem hiding this comment.
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
msipackager 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.
|
@copilot resolve the merge conflicts in this pull request |
|
@caarlos0 feedback / thoughts? |
|
@djgilcrease sorry was short on time this past few weeks, gonna review this now :D |
caarlos0
left a comment
There was a problem hiding this comment.
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 |
|
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. |
There was a problem hiding this comment.
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 --helpand 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)) orSystemFolder(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
.msiand 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/placedlookups 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
f1515f1 to
ab8ae70
Compare
ab8ae70 to
2c21981
Compare
2c21981 to
5d0d894
Compare
|
Added signing validation based on the fix in digitalxero/go-msix#4 |
caarlos0
left a comment
There was a problem hiding this comment.
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_dirreaching INSTALLFOLDER's DefaultDirall_users: falseminimal_ui- the
upgrade.enabled: falsedefault - 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.
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
80f830f to
d767d1d
Compare
|
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:
nfpm-side, beyond the inline items:
CI: the Windows job now installs, upgrades in place, uninstalls, and proves a failing |
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
d767d1d to
2048745
Compare
Adds an
msipackager 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
/Program Files,/ProgramData,/Windows/System32, ...); everything else lands under the product's own folder in Program Files (or%LOCALAPPDATA%\Programsforper_userinstalls).description/homepagebecome the Add/Remove Programs fields,vendor/maintainerthe manufacturer,type: licensecontents the install-wizard license text, and the rootscriptsbecome elevated deferred custom actions with deb/rpm hook semantics.msi:: advertised shortcuts, Windows services, registry values, per-user installs, a canned minimal UI, and Authenticode signing with a PFX (plus RFC 3161 timestamps).allow_downgrades/allow_same_versionare available). The upgrade code and component GUIDs are derived from identities that survive releases; the product code follows the full version.Verification
TestMSIStructure(Linux) builds amd64/arm64 packages from a real config.postinstallrolls the install back. A second job validates and installs a package that nfpm signed on Linux.Commits
chore(deps): go-msi v0.5.0, go-pkcs12 v0.7.3feat: add msi supportci: the signed-package validation jobThe MSIX behavior changes that used to live here are now #1135.