Automating a 15-Year-Old WiX Release Pipeline in Azure DevOps
How I moved a 15-year-old WinForms product’s build, RTM, and Service Pack process off a developer’s desktop and into Azure Pipelines, and everything that broke along the way.
If you’ve inherited a legacy Windows desktop product (WiX installer, .NET Framework, the whole vintage stack) and someone has asked you to “just automate the release process,” this one’s for you. Nothing here is exotic. It’s a story about old tools, undocumented assumptions, and the very specific, very reproducible ways they push back when you try to run them somewhere new.
The starting point
The product is a WinForms desktop app on .NET Framework 4.8, built from a handful of Visual Studio solutions: a data-access layer, a business-logic layer, a UI layer. Each one compiles to its own WiX merge module, and all three get embedded into one top-level MSI installer. Nothing unusual for software of a certain age.
What made this one interesting was how updates worked. Instead of shipping a fresh installer for every fix, the team shipped Windows Installer patches (.msp files): Service Packs, cumulative rollups of fixes since the last release, built with the classic Windows Installer “PatchCreation” toolchain (candle.exe → light.exe → msimsp.exe). An internal WPF tool used to drive that toolchain. It hadn’t been touched in years, and nobody wanted to be the person still running it by hand. My job was to replace it with something that lived in source control and ran itself.
The domain, in three minutes
If you haven’t worked with MSI-patch-based servicing before, the vocabulary matters:
- RTM - the first version a customer ever installs. A full MSI.
- Service Pack - a cumulative patch (
.msp) rolling up fixes since RTM (or the last Service Pack). - Service Release - a bigger re-baselining event. Once one ships, older patch history stops mattering, because customers are expected to have moved onto the new baseline.
Here’s the part that actually drives the design: building a patch requires every state a real customer might currently be running. An “UpgradeImage” (the new build) plus one or more “TargetImages” (the old ones), so the resulting .msp applies cleanly no matter which prior version someone has installed. That list of prior states needs to live somewhere durable. I ended up with a simple JSON file, committed to git, that grows by one entry every time something actually ships:
[
{
"type": "RTM",
"targetId": "RTM01",
"msiPath": "...\\RTM\\Product.Install.msi"
},
{
"type": "ServicePack",
"targetId": "SP1",
"zipPath": "...\\SP1\\Product.DataForSP1.zip"
}
]
Everything else in this post hangs off that one file.
The architecture that emerged
Three pipelines, each with exactly one job:
- CI - triggers automatically on every push to
main. Compiles, signs, archives the build. Never touches the release ledger above. - RTM - triggered manually, only when a build is genuinely shipping to customers. Same compile and sign steps, but this one commits an entry to the ledger.
- Service Pack - triggered manually with a version number and description. Takes a specific already-built MSI plus the entire ledger, and produces a signed patch.
Getting the split between CI and RTM right took a couple of tries, and it’s the single most important decision in this whole project: not every successful build is a release. A CI pipeline runs on every commit for reasons that have nothing to do with shipping: verifying a fix compiles, checking nothing broke. Write every one of those into the release ledger and it fills up with builds no customer ever had. Worse, a Service Pack build starts treating some random Tuesday-afternoon compile as a thing it has to stay compatible with forever. Once “build” and “this build is a release” became two pipelines with two different trigger models, the whole system got a lot easier to reason about.
CI and RTM share their compile and sign steps through an Azure Pipelines template, which is really just a YAML file with a jobs: block, referenced with - template: build-and-sign-job.yml from both callers. It’s the closest thing YAML has to a function. The WiX build-order dependency (module solutions before the installer solution, all in the same job) only has to be documented and gotten right once.
Why a legacy tool forces a self-hosted agent
Here’s the part that isn’t obvious until you’ve hit it, and the reasoning generalizes well beyond this one project.
Compiling and signing needs nothing special. Any disposable Windows agent handles that fine. Building the patch is different: it needs to read every historical release file from the ledger above, admin-installing each one (msiexec /a) to diff against the new build. If those historical files live somewhere only reachable from a specific network, an internal file share, say, then the build step itself needs to run on a machine that can reach it. Not just the final publish step. A disposable cloud agent has no route there for the several minutes the patch build takes.
I tried a few shapes before landing on the simplest one.
- First attempt: split the work across two jobs, compile and sign on a disposable agent, do only the final network-touching copy on a self-hosted one. Works fine when the network-only resource is purely a write target. Falls apart the moment the build itself needs read access mid-flight, which the patch step does.
- Second attempt: keep the disposable-agent compile step, but pull each historical file via the Azure DevOps REST API instead of a raw file path, downloading artifacts on demand. This technically works. It’s also a lot of moving parts (auth tokens, artifact retention windows, a whole resolution step) for a problem with a much simpler answer.
- What I actually shipped: run the whole patch-building job on the one self-hosted agent with network access, full stop. One job, one machine, no artifact gymnastics. Less clever. Considerably more maintainable.
The lesson: when a legacy tool needs sustained access to something a cloud agent can’t reach, don’t build an elaborate bridge. Accept that this specific job needs to live somewhere with a route to the data, and keep everything else on disposable infrastructure. Fight the constraint only where it actually bites.
The debugging log
This is the part I actually wanted to write down before I forgot it. Every one of these cost real time, and every one is the kind of thing you only find by reading the actual error, not the summary someone gives you of it.
1. pwsh.exe not recognized
First error, on a freshly registered self-hosted agent. Windows ships PowerShell 5.1 (powershell.exe) by default. PowerShell 7 (pwsh) is a separate install, and my pipeline’s steps all specifically requested the pwsh shell. Straightforward fix: install PowerShell 7.
2. …except the Microsoft Store package doesn’t work for services
Installing via winget without pinning a source pulled the Microsoft Store (MSIX) build of PowerShell 7. It ran fine interactively, then failed with the exact same “not recognized” error the moment the build agent’s Windows service tried to invoke it. MSIX-packaged apps expose their executables through “App Execution Aliases,” a mechanism tied to the interactive desktop session. It doesn’t reliably resolve for services or other non-interactive contexts. The fix was the traditional MSI installer instead: a real folder on PATH, not an alias.
# Prefer this over letting winget resolve to the Store source:
winget install --id Microsoft.PowerShell --source winget
# Or safest of all: the plain .zip release, extracted to a folder
# you add to the *System* PATH yourself.
3. A stray environment variable breaking module loading
Once pwsh was findable, it still failed to start cleanly:
Error in TypeData "System.Security.AccessControl.ObjectSecurity": The member AuditToString is already present.
...
'ConvertTo-SecureString' command was found ... but the module could not be loaded.
The culprit was a machine-level PSModulePath environment variable, overwritten (not appended to) by an unrelated tool’s installer, which caused PowerShell’s core security module to load twice from conflicting locations. Deleting the machine-level override and letting PowerShell recompute its own default fixed it. If you ever see “member is already present” errors on PowerShell startup, check $env:PSModulePath -split ';' for duplicates before you look anywhere else.
4. Failed to create patch. Error code: 0x7E
The legacy patch-creation executable failed with a bare hex code. 0x7E is ERROR_MOD_NOT_FOUND, a missing DLL dependency. I’d copied the tool’s .exe out of its original install location without its sibling files. The fix was checking what else lived alongside it and grabbing the one it actually needed: a “patch wizard” support DLL that isn’t obvious from the tool’s name at all. Lesson: never vendor a lone .exe copied out of an SDK folder without checking what’s sitting next to it.
5. Failed to create patch. Error code: 0xC00E5118
Progress, at least. The DLL loaded this time, and the failure moved further into the actual work. The patch tool’s own log file (written to the path you pass it, not just stdout) had the real answer:
ERROR: TargetImages.Target string '2026-08-12.1' is invalid; use 1 to 13 alphanumeric characters.
My build-number-derived identifier had a period in it. The default CI build-number format includes one, and the legacy patch schema is strict about “alphanumeric” meaning only letters and digits, no punctuation. Lesson, again: read the tool’s own log, not just its exit code. The exit code told me nothing. The log told me exactly what to fix.
6. PackageCode {...} is not unique
Once identifiers were valid, one more error surfaced: the “new” image and one of the “target” images had the same PackageCode. Windows Installer generates a fresh PackageCode automatically on every compile (as long as you don’t hardcode it), specifically so every distinct build is uniquely identifiable. But nothing stops you from accidentally reusing the same physical file as both the thing you’re patching to and one of the things you’re patching from. Which is exactly what I’d done: pointed the “new build” input at the same run already recorded as a patch target. The fix wasn’t code. It was process: always pick a genuinely new build for the “new” side of a patch, never the one already sitting in the ledger.
7. A PowerShell JSON gotcha that only shows up with exactly one item
The subtlest one. My “append an entry and write the file back” logic worked in testing, then broke the moment the ledger had exactly one entry:
# Looks right. Silently wrong when $entries has exactly one item.
$entries | ConvertTo-Json -Depth 10 | Set-Content $path
Piping a single-element collection into ConvertTo-Json unwraps it. PowerShell’s pipeline delivers that one object on its own, not as a one-item array, so ConvertTo-Json serializes it as a bare {...} instead of [{...}]. Every downstream consumer expecting an array chokes on it. The fix bypasses the pipeline’s enumeration entirely:
# Correct regardless of how many items are in the array.
ConvertTo-Json -InputObject $entries -Depth 10 | Set-Content $path
What makes this one nasty is that it only shows up at exactly one element. Everything works fine in every test you run with two or more, and breaks the very first time your data set legitimately has just one thing in it.
8. The pipeline that triggered itself
Once the RTM and Service Pack pipelines started committing their own entries back to the ledger file, every one of those commits, pushed to main, kicked off the CI pipeline all over again. For no reason: nothing buildable had changed. Azure Pipelines has no way to filter a trigger by who committed, but it does support filtering by what changed:
trigger:
branches:
include:
- main
paths:
exclude:
- path/to/release-ledger.json
A push whose only change is that one file no longer triggers a build. Anything that touches real code still does, even if it happens to touch that file too in the same push. Filtering by what changed turned out to be strictly better than filtering by who changed it. It’s more precise, and it doesn’t care whether the commit came from a pipeline’s service identity or a human editing the file by hand.
What I’d tell someone starting this from scratch
- Separate “built” from “released” as early as possible. It sounds like premature process. It isn’t. The moment something can compile without being a release, you need two different triggers, not one pipeline with a checkbox nobody will remember to tick correctly forever.
- Self-hosted agents are a last resort, but sometimes a correct one. Don’t reach for one out of habit, and don’t build elaborate workarounds to avoid one either. Figure out precisely which step needs the special access, and confine the self-hosted requirement to exactly that step, nothing more.
- Read the tool’s own log before the exit code. Every “mystery” error in this project had a plain-English explanation sitting in a log file the tool wrote itself. The exit code is a pointer. The log is the actual message.
- Legacy Windows tools have DLL dependencies that aren’t obvious from the filename. If you’re vendoring a binary out of an old SDK, grab its whole folder for context, or at minimum check what’s sitting next to it before assuming a lone
.exeis self-contained. - Test your data-shape assumptions at the boundary sizes. Zero items, one item, many items. The PowerShell JSON bug above only existed at exactly one, which is the most innocuous, most-likely-to-be-your-first-real-test size there is.
None of this was hard, exactly. It was a long chain of small, specific problems, each well-documented once you’d already found it, and each costing twenty minutes to an hour to actually root-cause. Writing them down felt worth doing. The fixes themselves are boring. Knowing which log to check and which assumption to question is the actual skill, and that part never shows up in a diff.