If you have tried to produce an MSIX package on a GitHub Actions Windows runner, you have probably written a step that goes hunting through C:\Program Files (x86)\Windows Kits\10\bin looking for makeappx.exe, because its path contains an SDK version number that changes when the runner image changes.

That step works until the image updates. Then it breaks, and it breaks in a way that looks like a packaging failure rather than a path failure.

There is a cleaner way, and it comes down to a detail about where MSIX packaging actually lives.

The thing worth knowing

MSIX packaging is not something the Windows SDK does. The AppxPackaging engine is a component of Windows itself. makeappx.exe is a command line wrapper around that engine which happens to ship inside the SDK.

So a tool can call the same operating system component directly, with no SDK on the machine at all. That is what forge_msix does. It is a drop-in replacement for makeappx.exe: same verbs, same switches, same expectations about input and output. If your workflow currently calls makeappx pack, you change the executable name and the step keeps working.

The makeappx-compatible verbs are pack, unpack, bundle, unbundle, validate and make-pri. Between them they cover the SDK side of a normal packaging job: build a package from a folder, take one apart to see what went in, combine per-architecture packages into a bundle, and build the resource index the manifest expects.

Flow diagram of an MSIX build on a GitHub Actions Windows runner with no Windows SDK installed: checkout and dotnet publish produce a build folder containing an app manifest, forge_msix pack calls the Windows AppxPackaging engine already present on the runner to turn that folder into an MSIX package, forge_msix validate gates the job by returning exit code 2 when the package is analysed and found invalid, forge_msix sign applies a timestamped signature from a certificate the runner can reach, and the signed package is uploaded as a workflow artefact and published through an App Installer update feed that installed clients check for new versions.

Build, pack, validate, sign, publish, with the packaging engine coming from Windows rather than from an SDK install.

Getting the tool onto the runner

The CLI ships with EtherApps Forge, so putting it on a runner is an ordinary tool install. Drop the executable somewhere on disk and append that folder to GITHUB_PATH so later steps can call it by name:

      - name: Add the packaging CLI to PATH
        run: echo "C:\tools\forge" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append

On a hosted runner that means one install step at the top of the job, cached like anything else. On a self-hosted runner it means installing once when you build the image. Either way you are provisioning a single command line executable rather than a development kit, which is the whole point.

Windows runners default to pwsh for run: steps, so the examples below assume PowerShell rather than cmd.

What the workflow step looks like

The point is how unremarkable it is.

jobs:
  package:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: dotnet publish -c Release -o publish/win-x64
      - name: Package as MSIX
        run: forge_msix pack /d .\publish\win-x64 /p .\artifacts\ContosoApp.msix
      - name: Validate
        run: forge_msix validate /p .\artifacts\ContosoApp.msix
      - uses: actions/upload-artifact@v4
        with:
          name: msix
          path: artifacts/ContosoApp.msix

No SDK install step. No path discovery. No version pinning against the runner image.

Three things reliably trip up the first run of that step.

The content directory needs an AppxManifest.xml at its root. A plain publish output does not have one unless your project produces it, and pack refuses a folder without it. This is the most common first failure, and it reads as a tooling problem when it is a project configuration problem.

Re-running over an existing output fails unless you pass /o. On a clean hosted runner you never see this. On a self-hosted runner with a persistent workspace you see it on the second build, which is when you have stopped watching.

Resource strings need a resources.pri. If your manifest refers to ms-resource: values and no resource index was built, the package can install and then show blank names in the Start menu. make-pri builds that index, and it belongs before pack.

If you would rather not stage files into a folder just to shape the package, use a mapping file instead of /d:

[Files]
"publish\win-x64\ContosoApp.exe" "ContosoApp.exe"
"publish\win-x64\AppxManifest.xml" "AppxManifest.xml"
"assets\Square150x150Logo.png" "Assets\Square150x150Logo.png"

Then forge_msix pack /f .\mapping.txt /p .\artifacts\ContosoApp.msix builds exactly what you listed and nothing else. When you are not sure what made it in, forge_msix unpack /p .\artifacts\ContosoApp.msix /d .\inspect gives the package back as a folder you can diff.

One more thing that belongs in the pipeline rather than in a person's head: the package version comes from the manifest, not the packaging command. Stamp it from the build before packing, and keep the fourth field at zero because that position is reserved.

$manifest = ".\publish\win-x64\AppxManifest.xml"
$xml = [xml](Get-Content $manifest)
$xml.Package.Identity.Version = "1.4.$env:GITHUB_RUN_NUMBER.0"
$xml.Save((Resolve-Path $manifest))

Updates only apply when the version increases, so a pipeline that ships the same version twice produces an update nobody receives and no error to explain why.

Fail the build, not the release

validate returns a non-zero exit code when the package is not valid, which is what lets the job fail at the point of the problem rather than at deployment.

One detail worth handling: exit code 2 means the package was analysed successfully and is invalid, as opposed to the tool failing to run. Treating those two cases identically produces a confusing red build. Treating them differently gives you a gate that tells you something useful.

Reading that exit code takes more care than it looks, because GitHub Actions runs pwsh steps with $ErrorActionPreference set to stop, and recent PowerShell versions apply that preference to native commands too. The step can therefore terminate on the non-zero exit before your comparison runs. Turn that off for the step and inspect the code yourself:

      - name: Validate
        shell: pwsh
        run: |
          $PSNativeCommandUseErrorActionPreference = $false
          forge_msix validate /p .\artifacts\ContosoApp.msix
          if ($LASTEXITCODE -eq 2) { throw "Package analysed and found invalid" }
          if ($LASTEXITCODE -ne 0) { throw "Validator failed to run, exit $LASTEXITCODE" }

The distinction matters more on a shared pipeline than on your own. "The package is wrong" is a message the person who broke it can act on. "The packaging step failed" sends them to the runner logs to work out which of the two things happened.

Signing in a pipeline

Packaging without signing produces an artefact you cannot install on a managed device, so the signing step belongs in the same job.

forge_msix sign /p .\artifacts\ContosoApp.msix `
  /thumbprint $env:SIGNING_THUMBPRINT `
  /timestamp http://timestamp.digicert.com

Two things that matter more than they look:

Sign by thumbprint from the certificate store, rather than passing a PFX and a password through workflow variables. The private key stays where it belongs.

Always timestamp, using an RFC 3161 server. Without it, the package stops validating the moment the signing certificate expires, even though the signature was created legitimately while the certificate was live. Every package you have ever shipped becomes uninstallable on the same day. With a timestamp, signatures outlive the certificate.

That first point decides where signing runs. A hosted runner is ephemeral and has no certificate store worth the name, and since the CA/Browser Forum tightened its code signing requirements in June 2023, publicly trusted signing keys have to live on certified hardware or in a cloud signing service, so a PFX in a repository secret is not an option anyway. In practice that means a self-hosted runner with the token or hardware module attached, or a hosted runner that authenticates to a signing service and never holds the key.

The failure signature to recognise is 0x8007000B on the signing step. It usually means the Publisher value in the manifest does not exactly match the subject of the signing certificate. Not approximately, exactly, including punctuation and spacing. The error text says nothing about publishers, which is why it costs an afternoon the first time.

Note that sign is one of the value-added verbs rather than one of the makeappx-compatible ones, so it needs an active EtherApps Forge licence. The same applies to create, test, assess, the cert- verbs and the dev- verbs.

Hosted runners versus self-hosted

On hosted runners the win is not having to install a multi-gigabyte SDK on every clean run, which is time on every single build.

On self-hosted runners the win is different: your image stops carrying the SDK, and stops being sensitive to which SDK version it carries. Two runners with different SDK versions can produce different packaging behaviour, which is among the least enjoyable classes of build failure to diagnose.

There is a governance angle as well. A development kit on build infrastructure is a large surface to justify to whoever approves what goes onto those machines, and it is doing one job: turning a folder into a package.

If your pipeline lives in Azure DevOps rather than GitHub Actions, the same reasoning applies unchanged. Building and signing MSIX in CI/CD covers the equivalent stages there, including the dev-register and dev-test verbs that shorten the local loop before anything reaches a pipeline.

Publishing the result

Once packages are produced and signed by the pipeline, distribution is the remaining question. make-appinstaller generates an App Installer update feed: publish the package and the feed, and existing installations check it for new versions. For an ISV shipping outside a managed estate, that is often the entire distribution story.

Two details decide whether it works first time. The feed and the package must be served over HTTPS with the correct content types, because a server that hands back .appinstaller or .msix as a generic binary stream produces a download prompt rather than an install. And the update behaviour is set in the feed itself, including how often clients check and whether an update applies on launch or in the background.

Both are easy to get right once and very annoying to diagnose from a user's description, which is an argument for validating the published feed from a clean machine as part of the release.

Where EtherApps Forge fits

forge_msix is the command line surface of EtherApps Forge, a Windows desktop application rather than a hosted service, with a free 7-day trial. That matters for a build pipeline: the tool runs on infrastructure you control and the packages never leave it.

The wider context is on the MSIX packaging for developers and ISVs route, which assumes what this article assumes: a healthy build folder, nothing to capture, and all the friction after the build succeeds. That is a different problem from the estate-wide one, where an application arrives with no installer and has to be rebuilt from a running machine. If that is your situation, MSIX packaging and deployment is the route and converting an EXE to MSIX covers it application by application. The 1.0.6 release notes explain where the developer and ISV capabilities came from.

Try it against your own workflow

The honest test is whether your existing packaging step keeps working when you change the executable name. Take a branch, swap makeappx for forge_msix in the pack and validate steps, and see whether the job goes green without touching anything else. If it does, the SDK dependency on your agent was never carrying its weight.

Explore MSIX packaging for developers and ISVs

The packaging engine is already on the runner; the only question is what you are installing to reach it.