CI/CD for WinForms / WPF Apps in Practice — Automating from Build to Signing and Distribution with GitHub Actions
· Go Komura · CI/CD, GitHub Actions, WinForms, WPF, C#, .NET, Code Signing, MSIX, Deployment, Windows Development, Decision Table
“The release can only be built on that one person’s machine.” This is something we hear constantly in consultations about WinForms and WPF business apps. Someone does a Release build in their local Visual Studio, zips it up, and drops it in a shared folder. It works, but nobody can say whether a bugfix release could ship on the day that developer happens to be out.
Information about CI/CD for web apps is everywhere, but the moment you get to desktop apps it suddenly thins out. Part of that is genuinely justified — “the deployment target is a customer’s PC, not a server” is a fundamental difference that means you can’t just copy a web article verbatim. That said, automating the build and test stages is nearly as little effort for a desktop app as it is for the web. The wall shows up further down the line, at signing and distribution, where the pragmatic answer differs by distribution format.
On this blog, we’ve already covered how to choose a distribution format in “A Decision Table for Windows App Distribution Methods” and how to think about signing in “SmartScreen and Code Signing.” Building on those two, this article works through, from a practical angle, how far you can take automation of building, testing, version numbering, signing, and producing distributables for a WinForms / WPF app using GitHub Actions.
1. The Bottom Line First
- The biggest risk is a state where “it can only be built on one developer’s PC.” The first goal of CI/CD isn’t full distribution automation — it’s making the build reproducible without depending on anyone’s particular machine.
- A minimal setup — just build+test automation — is worth a lot on its own. You can assemble it in a single YAML file with
windows-latest+actions/checkout+actions/setup-dotnet+dotnet build / test+actions/upload-artifact.12 - WinForms / WPF assume a Windows runner. Because they target a Windows-only TFM such as
net8.0-windows,3 running tests in CI requires a Windows environment. - Tag-driven versioning is the practical answer for version numbering. Push a
v1.2.3tag to trigger the release build, and inject the tag’s value into MSBuild’sVersionproperty.4 - Signing is the biggest wall to automation. Since June 2023, the private key of a public OV certificate has been required to live on an HSM, so the old standby of “put a PFX in a secret and run signtool” can no longer be used as-is. Going through a cloud signing service is the realistic path if you want CI to handle it end to end.5
- How easy a distribution format is to put on CI varies a lot. In order: xcopy (zip) is easiest, MSIX requires signing6, MSI works via a CLI-driven tool, and ClickOnce needs
msbuild /target:publishand has a fair number of quirks.7 - Don’t make automated UI testing a mandatory CI gate. Unit tests as a mandatory gate, with UI tests limited to smoke tests running in a separate job, is the practical answer.
2. How Desktop App CI/CD Differs From the Web
Let’s start by working through why you can’t just import the web app CI/CD pattern (push -> build -> test -> deploy to server) wholesale.
| Aspect | Web app | WinForms / WPF desktop app |
|---|---|---|
| Deployment target | A server you manage yourself | Customer/on-site PCs (outside your control) |
| Unit of distribution | A blanket switchover on the server | MSI / MSIX / ClickOnce / zip, and more — the timing of rollout depends on the other party |
| Rollback | Can revert on the server | Can’t easily revert on PCs that already got it. You must keep older installers around |
| Signing | Usually unnecessary (TLS is handled at the infrastructure layer) | Code signing on the executable/package is effectively mandatory |
| Build environment | Often works fine on a Linux runner | Assumes a Windows runner |
| Testing | Often works fine headless | Unit tests are the same; UI tests need a desktop session |
| What “deploy” means | Reaching production | CI’s job stops at “the distributable is complete.” Installation is a separate step |
The last row is the important one. For a desktop app, the exit point of the CI/CD pipeline isn’t “reaching production” — it’s “a signed distributable sitting somewhere it can always be retrieved from.” Everything past that (rolling it out to customers, auto-updating) is a matter of distribution design, which is the territory covered in “A Decision Table for Distribution Methods.” Put the other way around, once you draw that boundary at the exit, desktop app CI/CD can be built with exactly the same toolset as the web.
3. The Minimal Setup — Build+Test With GitHub Actions
Here’s the first and only thing you should put in place initially. Because GitHub-hosted runners assign a fresh VM to every job,1 every push runs the build and tests on a pristine Windows machine, and any dependency on “an SDK only installed on that one person’s PC” surfaces immediately.
name: build-and-test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: windows-latest # WinForms / WPF require a Windows runner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build
- name: Publish
run: dotnet publish src/MyApp/MyApp.csproj -c Release -o publish
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MyApp
path: publish
Three points worth calling out.
First, runs-on: windows-latest is the baseline. A WinForms / WPF project has a TargetFramework that’s a Windows-only TFM like net8.0-windows, and is a .NET desktop SDK project with UseWindowsForms or UseWPF enabled.3 Strictly speaking, if all you needed was compilation, you could enable EnableWindowsTargeting and build on a Linux runner too — but a step involving actual execution, like dotnet test, requires a Windows environment, so for a setup like this one that runs tests in the same job, using a Windows runner outright is the natural choice. For .NET Framework 4.x (the old-style csproj), you’d use MSBuild and the NuGet CLI instead of dotnet build, but both come preinstalled on the Windows runner, and the underlying approach is the same.
Second, always keep the output with actions/upload-artifact. “The full set of build outputs for that build can be retrieved from GitHub” is what actually breaking the dependency on one particular person’s machine looks like in practice. Even a quick sanity-check build becomes just a matter of downloading a zip from the Actions screen.
Third, at this stage, you’re not doing signing or distribution yet. This minimal setup alone buys you two guarantees — “main is always buildable and testable” and “anyone can retrieve the same build output” — and in my experience, that resolves the bulk of what a small team struggles with.
4. Automatic Version Numbering — Tag-Driven Releases
The next stage is solving the “so what version is this zip, exactly?” problem. In a workflow built around local builds, it’s a classic accident to forget to bump Version in the csproj, leaving the same 1.0.0 across many generations. The practical answer is tag-driven releases: tag the commit you want to release with something like v1.2.3, let that trigger a workflow, and inject the version taken from the tag name into the build.
name: release
on:
push:
tags: [ 'v*' ]
jobs:
release:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
# A tag push doesn't trigger the build+test workflow from Chapter 3,
# so run the tests here too before producing the release artifact
- name: Test
run: dotnet test --configuration Release
- name: Publish with version from tag
shell: pwsh
run: |
$version = $env:GITHUB_REF_NAME.TrimStart('v') # v1.2.3 -> 1.2.3
dotnet publish src/MyApp/MyApp.csproj `
-c Release -o publish `
-p:Version=$version
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MyApp-${{ github.ref_name }}
path: publish
Pass it as an MSBuild property like -p:Version=1.2.3, and in a .NET SDK project, AssemblyVersion and FileVersion are generated by default from Version’s prefix (the part with any suffix removed), and InformationalVersion from Version itself.4 Keep only a placeholder dev value in the csproj, and the release’s official version becomes something that lives solely, in one place, on the tag.
There’s one caveat. Artifacts from actions/upload-artifact have a repository-level retention period (90 days by default), and they’re deleted once it expires. Because a desktop app needs to keep old installers around long-term for rollback purposes, publish tag-build artifacts to a permanent location, such as attaching them to a GitHub Release, and treat the Actions artifact as a purely temporary handoff.
The benefit is that operations stay entirely within Git. “Which commit is 1.2.3 in the customer’s environment?” is pinned down by the tag, and the file version shown in the EXE’s properties matches the Git tag mechanically. On top of that, starting with the .NET 8 SDK, InformationalVersion gets the Git commit hash (SourceRevisionId) appended by default,4 so if you surface this on a version display screen, you can pin down the exact commit directly from a build artifact.
5. Wiring Code Signing Into CI — This Is the Biggest Wall
Teams that got build and versioning automated smoothly almost always come to a halt at signing. We already covered why code signing is effectively mandatory for a Windows app distributed outside the Store (SmartScreen, corporate security products, tamper detection) in “SmartScreen and Code Signing,” so here we’ll focus narrowly on where in CI to run it, and how.
5.1. The basic form of signtool
Actually running the signing is just one command. signtool ships as part of the Windows SDK and is available on GitHub’s Windows runners too. Current SDK versions require specifying both /fd (file digest) and /td (timestamp digest), with SHA256 recommended.8
signtool sign /f MyCert.pfx /p $env:PFX_PASSWORD `
/fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
publish\MyApp.exe
The timestamp (/tr) is technically optional, but always include it. With a timestamp, you can prove “this was valid at the time it was signed” even after the certificate expires, keeping the signature on files you’ve already shipped alive.86
5.2. Certificate types and the reality of putting them on CI
The issue isn’t the command — it’s where the private key lives. How you get your certificate fundamentally changes how you can wire it into CI.
| Certificate form | Private key location | Wiring into CI | Notes |
|---|---|---|---|
| Cloud signing service (Azure Artifact Signing = formerly Trusted Signing, etc.) | Cloud-side | Easy to wire in (the main option). Designed with GitHub Actions integration and the like in mind from the start | Restricted to certain available countries/regions (US, Canada, EU, UK, etc. for businesses)5 |
| OV certificate (newly issued since June 2023) | HSM / USB token required | Not possible as-is, since a token can’t be plugged into a runner. Possible via a CA’s cloud HSM option | Per CA/Browser Forum requirements5 |
| EV certificate | HSM / USB token | Same as above | The instant-trust benefit for SmartScreen was retired in 2024 — treat it as on par with OV for signing operations5 |
| Legacy PFX file (issued in the past, an internal CA, or self-signed) | A file | Store it Base64-encoded in a secret and restore it (below) | This form generally can no longer be obtained for a newly issued certificate for public distribution |
In other words, the “put a PFX in a GitHub secret and sign with signtool” setup you’ll commonly find via search is still valid for an internal CA or an existing PFX, but the premise breaks down for anyone acquiring a public certificate going forward. If you’re setting this up fresh, it’s realistic to plan around a cloud signing service that supports CI integration from the outset.5 If you’re operating with a USB token, you end up with a hybrid setup where only the signing step stays on a local PC or a self-hosted runner with the token plugged in.
5.3. Secret management caveats
Here are the standard practices for putting the PFX approach (an internal CA or existing certificate) on CI.
- Store the PFX as a Base64 string in a GitHub secret, and restore it to a file inside the job. This is the procedure GitHub’s own docs recommend for handling binary data as a secret.9
- Keep the password in a separate secret. Secret values are automatically masked in logs,9 but that masking doesn’t extend to derived values you’ve transformed. Avoid passing them as environment variables to anything beyond the signing step.
- Secrets aren’t passed to pull requests from forks (except
GITHUB_TOKEN).9 However, the job itself still runs with empty secrets, so the restore step above will fail while trying to Base64-decode an empty string. Either keep the signing step confined to a tag-triggered release workflow like the one in Chapter 4 (which doesn’t fire on a fork PR), or add an explicit condition such asif: github.event_name != 'pull_request'to skip it deliberately.
- name: Restore signing certificate
shell: pwsh
run: |
$bytes = [Convert]::FromBase64String($env:PFX_BASE64)
[IO.File]::WriteAllBytes("$env:RUNNER_TEMP\sign.pfx", $bytes)
env:
PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}
One more critical point: restrict which workflows can access the signing key. Anyone with write access to the repository can modify a workflow, so put the signing secret in an Environment with required reviewers, so that only the release workflow can reference it. A signed binary is itself the proof that “your company built this,” so treat the key with the same trust-boundary rigor you’d apply to an auto-update delivery pipeline (see also “The Security of Auto-Updates” on this point).
6. A Decision Table for CI/CD Integration by Distribution Format
Once signing is in place, the last piece is the shape of the distributable itself. We’ll leave the choice of format to “A Decision Table for Distribution Methods” and compare purely from a CI/CD standpoint here.
| Distribution format | Ease of building in CI | How to build it in CI | Signing requirement | Auto-update |
|---|---|---|---|---|
| xcopy (zip distribution) | Easiest | Just dotnet publish + compression |
Signing the EXE/DLL (recommended) | None (manual deployment) |
| xcopy + a homegrown updater | Easy (for the build itself). Update-delivery design is a separate, heavy lift | dotnet publish + manifest generation |
EXE signing plus a design for verifying update files is mandatory | Homegrown (requires designing a trust boundary) |
| MSI | Moderate | Running a tool like WiX from the CLI | Signing the MSI file (recommended-to-effectively-mandatory) | None (needs a separate distribution mechanism) |
| MSIX | Moderate | MSBuild / MakeAppx + signtool | Package signing is mandatory (an unsigned package can’t be installed)6 | Can be handled via App Installer, etc. |
| ClickOnce | Full of quirks | msbuild /target:publish + a publish profile (not supported via the dotnet CLI)7 |
Manifest signing plus EXE signing | Built in (the format’s main selling point) |
A few supplementary notes.
- xcopy (zip): the workflows from Chapters 3 and 4 are essentially the finished form as-is. Whatever the final distribution format ends up being, getting this shape working first is the shortest path.
- MSI: include the installer definition (WiX, etc.) in the repository and build it via the CLI. The real substance here isn’t the generation step itself but the design question of “what goes into the MSI” (service registration, per-machine vs. per-user).
- MSIX: because Windows won’t allow an unsigned MSIX to be installed, you can’t call CI-ification complete without automated signing baked in alongside it.6 On the flip side, once your signing infrastructure is in place, this is a format that’s easy to put on CI. If you distribute through the Microsoft Store, there’s an alternative path — Store-side re-signing means you don’t need your own certificate at all.5
- ClickOnce: this can’t be published via the dotnet CLI — you need
msbuild /target:publish /p:PublishProfile=...with a publish profile (.pubxml) specified. The revision number (ApplicationRevision), which the IDE auto-increments on every publish, does not auto-increment on the command line,7 which makes the explicit tag-driven version passing from Chapter 4 essential. One caveat while doing this: ClickOnce’s update check is driven not by-p:Version(the assembly info) but by the deployment-side version (ApplicationVersion/ApplicationRevision), so unless you separately pass the four-part value built from the tag, as in/p:ApplicationVersion=1.2.3.0, a new release won’t get recognized as an update. We cover the mechanism and when it’s a good fit in “What Is ClickOnce.”
Purely from a CI/CD standpoint, “start with zip, then add MSIX or MSI as a job once distribution requirements settle” is the path with the least incremental waste. The earlier stages (build, test, versioning) are shared across every format, so swapping in a distribution-format step later doesn’t throw away any of that earlier investment.
7. How Far to Take Test Automation
Finally, let’s draw the line on how much testing to require as a CI gate (a mandatory check).
| Test layer | Treatment in CI | Reason |
|---|---|---|
| Unit tests (logic) | Mandatory gate. Runs on every pull request | Fast, stable, and runs as-is on a Windows runner |
| UI-less integration tests (DB, file I/O) | Mandatory in principle; split off to a nightly run if slow | Setting up external dependencies takes some care, but the automation payoff is high |
| Automated UI tests (smoke) | A small number, in a separate job | Requires a desktop session and has plenty of sources of instability |
| Automated UI tests (comprehensive coverage) | Don’t make this a CI gate | Maintenance cost tends to outweigh the benefit |
For a desktop app, the automation with the highest payoff isn’t the UI — it’s the layer beneath it. If business logic is buried in code-behind, you can’t write unit tests for it, so separating logic from the screen is itself a prerequisite investment for CI/CD. The practical answer is to keep automated UI tests limited to a smoke test — “launch, log in, the main screens open” — run on a separate trigger such as nightly. UI tests on a runner come with plenty of traps around screen sessions, resolution, and timing, and we cover those, including the CI and unattended-execution pitfalls, in “Automated UI Testing for Windows Desktop Apps.”
8. Summary
- Desktop app CI/CD can be built with the same toolset as the web, once you define the exit point as “producing a signed distributable.”
- The minimal setup is
windows-latest+actions/checkout+actions/setup-dotnet+dotnet build / test+actions/upload-artifact. That alone eliminates the “can only be built on one developer’s PC” risk.12 - WinForms / WPF assume a Windows runner, because they target a Windows-only TFM such as
net8.0-windows.3 - Centralize versioning around tag-driven releases: a
v1.2.3tag flows into-p:Version.4 - Signing is the biggest wall to CI automation. Now that OV certificates also require HSM storage, a cloud signing service is the main option if you want CI to handle it end to end; the PFX-plus-secret approach is really for an internal CA or an existing certificate.59
- Ease of putting a distribution format on CI runs, in order, xcopy (zip) -> MSI / MSIX -> ClickOnce. MSIX requires signing,6 and ClickOnce needs care around
msbuild /target:publishand its non-auto-incrementing revision.7 - Make unit tests a mandatory CI gate, keep UI tests limited to smoke tests in a separate job, and treat separating logic from the screen as the prerequisite investment.
Related Articles
- A Decision Table for Windows App Distribution Methods — MSI / MSIX / ClickOnce / xcopy / a Custom Updater
- Why Windows Shows “Windows Protected Your PC” — SmartScreen and Code Signing
- What Is ClickOnce — Working Through the Mechanism, Updates, and When It Is and Isn’t a Good Fit
- An Auto-Updater Is a Trust Boundary — Why HTTPS Alone Isn’t Enough
- Automated UI Testing for Windows Desktop Apps
Related Consulting Areas
Komura Software LLC handles WinForms / WPF app development, migrating teams off local-build-only operations onto CI/CD, designing build/sign/distribution pipelines with GitHub Actions, and making existing desktop apps testable by separating out their logic.
- Windows App Development
- Modernization & Maintenance of Existing Windows Software
- Technical Consulting & Design Review
- Contact
References
-
GitHub Docs, GitHub-hosted runners reference. On runner labels such as
windows-latest, a fresh virtual machine being assigned per job, and standard runners being free on public repositories. ↩ ↩2 ↩3 -
Microsoft Learn, GitHub Actions and .NET. On .NET CI/CD via GitHub Actions, the roles of actions/checkout and actions/setup-dotnet, and using dotnet restore / build / test / publish within a workflow. ↩ ↩2
-
Microsoft Learn, MSBuild reference for .NET Desktop SDK projects. On WinForms / WPF projects specifying a Windows-specific TFM such as
net8.0-windows, and enabling the .NET desktop SDK viaUseWindowsForms/UseWPF. ↩ ↩2 ↩3 -
Microsoft Learn, Set assembly attributes in a project file. On
AssemblyVersion/FileVersion(suffix removed) andInformationalVersionbeing generated by default from theVersionproperty, and on the .NET 8 SDK and later appendingSourceRevisionId(the commit hash) toInformationalVersion. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, Code signing options for Windows app developers. On CA/Browser Forum requirements mandating HSM/hardware-token storage for OV certificate private keys since June 2023, EV certificates’ first-run SmartScreen bypass being retired in 2024, Azure Artifact Signing (formerly Trusted Signing) requiring no token and integrating with GitHub Actions and the like while being restricted to certain regions, and Microsoft re-signing MSIX packages distributed through the Store. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Sign an MSIX package. On Windows requiring a valid code signature on an MSIX package, and on a timestamp keeping signature verification valid even after the certificate expires. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Build .NET ClickOnce applications from the command line. On .NET ClickOnce publishing requiring
msbuild /target:publishwith a publish profile specified, and onApplicationRevisionnot auto-incrementing in command-line builds. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, SignTool. On SignTool being included in the Windows SDK, current builds requiring
/fdand/tdwith SHA256 recommended, and specifying an RFC 3161 timestamp via/tr. ↩ ↩2 -
GitHub Docs, Using secrets in GitHub Actions. On secret values being automatically redacted from logs, the procedure for storing binary data like certificates as Base64 in a secret and restoring it inside a job, and secrets not being passed to workflows triggered from forks. ↩ ↩2 ↩3 ↩4
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Windows App Outsourcing and Contract Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or contract development, here is how to sort out existing software modification, device inte...
System Tray Icons and Toast Notifications in Windows Apps — NotifyIcon Pitfalls and Choosing the Right AppNotification API
A practical guide to keeping a business Windows app resident in the system tray and notifying users with toast notifications. Covers the ...
UI Automated Testing for Windows Desktop Apps — How UI Automation Works and Building Robust Tests with FlaUI
A practical guide to UI automated testing for WinForms/WPF apps, working from how Windows UI Automation itself works (the tree, Automatio...
Why Use the .NET Generic Host and BackgroundService in Desktop Apps
How to use the Generic Host and BackgroundService to organize startup, periodic processing, shutdown, logging, configuration, and DI in W...
Versioning Your Business App's Database Schema — Migration Practices to Prevent 'Every Customer Has a Different DB'
A practical guide to versioning the database schema of a business app whose databases are scattered across customer sites. Covers PRAGMA ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
UI Threading & Timers
Topic page for WPF / WinForms UI threading, async flow, Dispatcher usage, and timer decisions.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Windows Software Maintenance & Modernization
We support staged upgrades, feature additions, 64-bit readiness, and maintainable restructuring for existing Windows software.
Frequently Asked Questions
Common questions about the topic of this article.
- Which GitHub Actions runner should I use to build a WinForms / WPF app?
- Use a Windows runner such as windows-latest. WinForms / WPF projects target a Windows-only target framework like net8.0-windows, and both verifying the build output and running tests with dotnet test require a Windows environment. Because GitHub-hosted runners assign a fresh virtual machine to every job, you get reproducible builds that don't depend on any particular developer's PC. Standard runners are free on public repositories and billed by the minute on private ones.
- Can code signing be fully automated in CI?
- It depends on how you hold the certificate. The classic approach of putting a PFX file in a secret and signing with signtool generally can't be used with a newly issued certificate anymore, because CA/Browser Forum requirements have mandated HSM (hardware) storage for the private keys of public OV certificates since June 2023. A USB-token certificate can't be plugged into a cloud runner, so if you want full automation in CI, the realistic path is a cloud signing service such as Azure Artifact Signing (formerly Trusted Signing), or a cloud HSM offered by the CA. If you're staying on token-based operation, the signing step itself has to stay on a local machine or a self-hosted runner.
- Where should I start automating first?
- Start with just build+test automation. Simply getting to a state where dotnet build / dotnet test run on a windows-latest runner on every push eliminates the biggest risk: 'this can only be built on one developer's PC,' and 'nobody notices a merge broke the build until right before a release.' Automating signing, installer creation, and distribution can be added incrementally after that — trying to wire up everything at once tends to stall out around signing.
- Does the ease of setting up CI/CD change depending on the distribution format (MSI / MSIX / ClickOnce / xcopy)?
- Yes, significantly. xcopy distribution (zip) is the simplest, since it's just compressing the output of dotnet publish. MSIX can be put on CI with MSBuild and signtool, but signing the package is mandatory. MSI can be automated by invoking a tool like WiX from CI. ClickOnce can't be published through the dotnet CLI at all — it needs msbuild /target:publish combined with a publish profile, and you also need to watch out for the fact that the revision number isn't incremented automatically from the command line. How easy a format is to put on CI should be one of the factors you weigh when choosing a distribution format in the first place.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links