CI/CD for WinForms / WPF Apps in Practice — Automating from Build to Signing and Distribution with GitHub Actions

· · 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.3 tag to trigger the release build, and inject the tag’s value into MSBuild’s Version property.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:publish and 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 as if: 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.3 tag 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:publish and 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.

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.

References

  1. 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

  2. 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

  3. 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 via UseWindowsForms / UseWPF 2 3

  4. Microsoft Learn, Set assembly attributes in a project file. On AssemblyVersion / FileVersion (suffix removed) and InformationalVersion being generated by default from the Version property, and on the .NET 8 SDK and later appending SourceRevisionId (the commit hash) to InformationalVersion 2 3 4

  5. 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

  6. 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

  7. Microsoft Learn, Build .NET ClickOnce applications from the command line. On .NET ClickOnce publishing requiring msbuild /target:publish with a publish profile specified, and on ApplicationRevision not auto-incrementing in command-line builds.  2 3 4

  8. Microsoft Learn, SignTool. On SignTool being included in the Windows SDK, current builds requiring /fd and /td with SHA256 recommended, and specifying an RFC 3161 timestamp via /tr 2

  9. 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

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Back to the Blog