CI/CD with GitHub Actions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Everything earlier in this section can be done by hand on a developer machine: restore, build, test, publish, package, sign, upload. That is fine for the first release and wrong for the tenth, because a manual release depends on one person’s laptop, one person’s certificate and one person’s memory of the steps. A continuous integration and delivery (CI/CD) pipeline moves those steps into a versioned YAML file that runs the same way on every change and on every release tag. This page builds that pipeline for Bookshelf Desktop, the running scenario of this section, in three workflows: one that verifies every pull request, one that produces and signs release artifacts when a tag is pushed, and a small set of jobs that push the result to GitHub Releases, WinGet and the Microsoft Store.

The page was written against these versions, current in October 2026:

  • .NET 10 LTS, latest patch 10.0.13, with C# 14. .NET 11 is at release candidate 2; the workflows below pin 10.0.x and need only a one-line change when .NET 11 ships.

  • Visual Studio 2026 18.11.1 and the Windows App SDK 2.5.1 for the WinUI 3 packaging job. The runners documented below ship Visual Studio 2026 on the windows-2025-vs2026 image.

  • CommunityToolkit.Mvvm 8.4.2 in the projects under test. The pipeline itself is independent of it.

  • Windows 11 and Windows 10 as install targets. The runner operating system is a separate matter, covered next.

Packaging concepts (identity, manifest, publisher subject), the choice of channel and the update mechanisms are covered by the pages titled MSIX Packaging & Signing, Distribution Channels and Updates, Versioning & Telemetry in this section; this page only automates them. The action versions below are the latest majors found on GitHub on 2026-10-23.

The Pipeline at a Glance

The release pipeline is a directed graph of jobs. Everything in the left half runs on every pull request; the right half runs only for a version tag and only after the left half passed.

flowchart LR A["Push or pull request"] --> B["Restore, build, test"] B --> C{"Version tag?"} C -- "No" --> Z["Stop: PR checks only"] C -- "Yes" --> D["dotnet publish per RID (WPF, WinForms)"] C -- "Yes" --> E["MSBuild MSIX package (WinUI 3)"] E --> F["Sign with Artifact Signing (OIDC)"] D --> G["Upload workflow artifacts"] F --> G G --> H["GitHub Release with .appinstaller"] H --> I["wingetcreate update to winget-pkgs"] H --> J["msstore publish to the Microsoft Store"]

Two properties of this graph matter more than any single step. First, build once, publish many: the exact files that passed the tests are the ones signed and released; nothing is rebuilt in a later job. Second, credentials only where they are needed: only the signing job can obtain a signing token, only the WinGet job holds a GitHub token for the winget-pkgs fork, and only the Store job holds Partner Center credentials.

Choosing Runners and Pinning Actions

A desktop pipeline needs Windows because the Windows desktop SDK, MSBuild’s packaging targets and SignTool exist only there. GitHub hosts Windows runners, selected by the runs-on label.

Label Image Use for Bookshelf Desktop

windows-latest

Resolves to the newest stable Windows Server image (currently Windows Server 2025). The migration of the label to a newer image is gradual, so it can change under a workflow.

Pull-request checks, where a newer image is useful early warning.

windows-2025 or windows-2025-vs2026

A fixed image; the second name selects the image with Visual Studio 2026.

Release builds that must be reproducible.

windows-2022

Windows Server 2022.

Only when a tool needs the older image.

windows-11-arm

Windows 11 on Arm64.

Running the tests natively on Arm64.

Each third-party action is referenced by a tag. The table lists the majors used on this page, checked on GitHub on 2026-10-23. A major tag moves, so the table is a starting point: dependabot can keep the uses: lines current, and a stricter policy is to pin to a full commit SHA with the major as a trailing comment.

Action Pinned Purpose here

actions/checkout

v7

Clones the repository (latest release 7.0.1).

actions/setup-dotnet

v6

Installs the .NET SDK (latest release 6.0.0).

microsoft/setup-msbuild

v3

Puts MSBuild on PATH for the MSIX packaging targets.

actions/upload-artifact

v7

Stores build outputs for later jobs and for download (7.0.2).

actions/download-artifact

v8

Retrieves them in a later job (8.0.2).

azure/login

v3

Signs in to Azure with OpenID Connect, no stored password (3.1.0).

Azure/artifact-signing-action

v2

Signs files with Artifact Signing (2.0.0).

softprops/action-gh-release

v3

Creates a GitHub Release and attaches files (3.0.3). Community action.

microsoft/microsoft-store-apppublisher

v1

Installs the Microsoft Store Developer CLI (1.4).

Two details deserve emphasis. The Learn articles consulted for this page still show older majors (checkout@v4, setup-dotnet@v4, microsoft-store-apppublisher@v1.1); those examples work, but the table above is what GitHub reports today. And the signing action is published as Azure/artifact-signing-action: it is the same repository that was previously named azure/trusted-signing-action (the service was renamed from Trusted Signing to Artifact Signing), so workflows written against the old name keep resolving through a redirect, and new ones should use the new name.

Workflow 1: Restore, Build and Test on Every Change

The first workflow gives a pull request its green or red check. It restores, builds in Release, and runs the unit tests of Bookshelf.Tests, which cover the view models without a UI (see the page on testing view models).

name: ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-test:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x

      - name: Restore
        run: dotnet restore Bookshelf.slnx

      - name: Build
        run: dotnet build Bookshelf.slnx --configuration Release --no-restore

      - name: Test
        run: >
          dotnet test Bookshelf.slnx
          --configuration Release --no-build
          --logger "trx;LogFileName=results.trx"
          --results-directory TestResults

      - name: Upload test results
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v7
        with:
          name: test-results
          path: TestResults/

The choices in that file are deliberate:

  • permissions: contents: read replaces the default token permissions with the minimum. Each job later widens it only where needed.

  • concurrency cancels a superseded run of the same branch, which saves runner minutes on a busy pull request.

  • --no-restore and --no-build make each step do one thing; dotnet test and dotnet publish would otherwise restore and build again implicitly.

  • if: ${{ !cancelled() }} uploads the results even when the tests failed, which is exactly when they are needed.

  • The default configuration of dotnet publish is Release for projects targeting .NET 8 or later, but build and test still default to Debug, so the workflow names --configuration Release explicitly everywhere.

Workflow 2: Publish per Runtime Identifier

WPF and Windows Forms apps are normally shipped unpackaged or inside an MSIX produced from a published folder. Either way the first step is dotnet publish for each target runtime identifier (RID). A matrix runs one job per RID; a Windows x64 runner can cross-compile the win-arm64 build because both are managed code with a native host.

name: publish-desktop

on:
  workflow_call:
    inputs:
      version:
        required: true
        type: string

permissions:
  contents: read

jobs:
  publish:
    runs-on: windows-2025
    strategy:
      fail-fast: false
      matrix:
        rid: [win-x64, win-arm64]
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x

      - name: Publish self-contained
        env:
          APP_VERSION: ${{ inputs.version }}
          RID: ${{ matrix.rid }}
        run: >
          dotnet publish src/Bookshelf.Desktop/Bookshelf.Desktop.csproj
          --configuration Release
          --runtime $env:RID
          --self-contained true
          -p:PublishSingleFile=true
          -p:Version=$env:APP_VERSION
          --output artifacts/$env:RID

      - name: Zip
        env:
          RID: ${{ matrix.rid }}
        run: Compress-Archive -Path "artifacts/$env:RID/*" -DestinationPath "Bookshelf.Desktop-$env:RID.zip"

      - uses: actions/upload-artifact@v7
        with:
          name: bookshelf-desktop-${{ matrix.rid }}
          path: Bookshelf.Desktop-${{ matrix.rid }}.zip
          if-no-files-found: error

The options map to the publishing choices described in the page .NET Publishing Options:

Option Effect

--runtime <RID>

Publishes for one runtime identifier. The documented advice is to pair it with --self-contained or --no-self-contained. When publishing a solution, RuntimeIdentifiers in the project file avoids the symbols caveat of the CLI.

--self-contained true

Includes the .NET runtime in the output so the target needs no installed runtime.

-p:PublishSingleFile=true

Packs the app into one platform-specific executable; it implicitly sets self-contained.

-p:PublishReadyToRun=true

Precompiles assemblies to the ReadyToRun format for faster startup; the documentation recommends setting it in a publish profile rather than on the command line.

--output <dir>

Output folder; on a solution the option is rejected, so the workflow publishes the project, not the solution.

--no-build / --no-restore

Skip the build or restore when an earlier step already did it.

The version is passed as an environment variable and read as $env:APP_VERSION. Interpolating ${{ inputs.version }} directly into the script text is the habit to avoid, because the expression is substituted before the shell runs and a crafted value would become part of the command; an env: entry is just data.

Workflow 3: Building the MSIX with MSBuild

A WinUI 3 app uses single-project MSIX, so the package comes out of the project build. For this the pipeline calls MSBuild directly. The Azure Pipelines MsixPackaging@1 task that older tutorials mention is outdated; the Learn guidance for continuous integration is to call MSBuild with the packaging properties, which is what the job below does. It also needs the Visual Studio build tools that the Windows runner images already contain.

  package-msix:
    needs: version
    runs-on: windows-2025-vs2026
    strategy:
      fail-fast: false
      matrix:
        platform: [x64, arm64]
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x

      - uses: microsoft/setup-msbuild@v3

      - name: Stamp the package version
        env:
          VERSION: ${{ needs.version.outputs.value }}
        run: |
          $path = Resolve-Path src/Bookshelf.WinUI/Package.appxmanifest
          [xml]$manifest = Get-Content $path
          $manifest.Package.Identity.Version = "$env:VERSION.0"
          $manifest.Save($path.Path)

      - name: Restore
        run: msbuild src/Bookshelf.WinUI/Bookshelf.WinUI.csproj /t:Restore /p:Configuration=Release /p:Platform=${{ matrix.platform }}

      - name: Build the unsigned package
        run: >
          msbuild src/Bookshelf.WinUI/Bookshelf.WinUI.csproj
          /p:Configuration=Release
          /p:Platform=${{ matrix.platform }}
          /p:GenerateAppxPackageOnBuild=true
          /p:UapAppxPackageBuildMode=SideloadOnly
          /p:AppxBundle=Never
          /p:AppxPackageSigningEnabled=false
          /p:AppxPackageDir="${{ github.workspace }}\Packages\${{ matrix.platform }}\"

      - uses: actions/upload-artifact@v7
        with:
          name: msix-unsigned-${{ matrix.platform }}
          path: Packages/${{ matrix.platform }}/**/Bookshelf.WinUI_*.msix
          if-no-files-found: error

The properties come from that article and are the ones to know:

MSBuild property Meaning in CI

GenerateAppxPackageOnBuild=true

Makes the build produce the MSIX package.

AppxPackageDir

Folder that receives the package.

UapAppxPackageBuildMode=SideloadOnly

A package for sideloading (signed by you). StoreUpload produces the .msixupload for Partner Center instead.

AppxBundle=Never / Always

One package per platform, or a bundle. A Store upload built in one invocation uses AppxBundle=Always with AppxBundlePlatforms=x86|x64.

AppxPackageSigningEnabled=false

Leaves the package unsigned so a later job signs it. The Store re-signs a submission itself, so a .msixupload build disables signing too.

The stamp step writes the tag version into the Identity element of the manifest, so the package version, the package file name (Bookshelf.WinUI_<version>_<platform>.msix) and the generated .appinstaller agree. MSBuild places a sideload package in a Bookshelf.WinUI_<version>_<platform>_Test subfolder, next to install scripts and a Dependencies folder of framework packages; the upload filter keeps only the app package, so the framework packages are neither uploaded nor re-signed.

Separating "build" from "sign" is a design decision, not a limitation. The build job has no secrets and can run for pull requests from forks; the sign job is the only one with identity, and it runs only for release tags.

Signing

An MSIX package must be signed with a certificate whose subject equals the Publisher in Package.appxmanifest, or Windows refuses to install it; unpackaged executables should be signed for SmartScreen reputation. The pipeline has two ways to do this without a certificate file in the repository.

Artifact Signing is the Azure service, formerly named Trusted Signing, that holds the certificates in hardware security modules and signs digests on request, so no private key ever reaches the runner or the repository. In Azure you create a signing account containing an identity validation and a certificate profile. The workflow identity needs the role named Artifact Signing Certificate Profile Signer on that account or profile. The recommended authentication is OpenID Connect (workload identity federation): the job requests a short-lived token from GitHub and azure/login exchanges it, so the three values stored as secrets are identifiers, not passwords.

  sign:
    needs: package-msix
    runs-on: windows-2025
    environment: release
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/download-artifact@v8
        with:
          pattern: msix-unsigned-*
          path: unsigned
          merge-multiple: true

      - name: Azure login (OIDC)
        uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Sign with Artifact Signing
        uses: Azure/artifact-signing-action@v2
        with:
          endpoint: ${{ vars.SIGNING_ENDPOINT }}
          signing-account-name: ${{ vars.SIGNING_ACCOUNT }}
          certificate-profile-name: ${{ vars.SIGNING_PROFILE }}
          files-folder: ${{ github.workspace }}\unsigned
          files-folder-filter: msix
          files-folder-recurse: true
          file-digest: SHA256
          timestamp-rfc3161: http://timestamp.acs.microsoft.com
          timestamp-digest: SHA256

      - name: Collect the signed packages
        run: |
          New-Item -ItemType Directory -Force signed | Out-Null
          Get-ChildItem unsigned -Recurse -Filter 'Bookshelf.WinUI_*.msix' | Copy-Item -Destination signed

      - uses: actions/upload-artifact@v7
        with:
          name: msix-signed
          path: signed/*.msix
          if-no-files-found: error

The inputs, as documented in the action’s readme:

Input Meaning

endpoint

Regional endpoint of the signing account; it must match the region of the account and certificate profile.

signing-account-name, certificate-profile-name

The two resources that identify the signer.

files, files-folder, files-folder-filter, files-folder-recurse, files-folder-depth, files-catalog

What to sign: explicit files, or a folder with an extension filter.

file-digest

SHA256, SHA384 or SHA512; the default is SHA256.

timestamp-rfc3161, timestamp-digest

Timestamp server and digest. The readme recommends http://timestamp.acs.microsoft.com with SHA256.

The action runs only on Windows runners (windows-2025 and windows-2022 are listed). Non-secret values such as the account name live in repository variables (vars.), while the three Azure identifiers are stored as secrets and referenced only as ${{ secrets.NAME }}. The environment: release line attaches the job to a GitHub environment, where you can require a reviewer before the job, and therefore the signature, is allowed to run.

The action also supports client secret and username-and-password authentication through DefaultAzureCredential; the readme recommends OIDC, and the username form does not support multi-factor authentication. On the Azure side the setup also needs a federated credential on the app registration that trusts this repository and the release environment. Availability and eligibility of the service are limited by region (organizations in the USA, Canada, the European Union and the United Kingdom, and individual developers in the USA and Canada); MSIX Packaging & Signing lists the requirements.

SignTool with a certificate from a secret

If you own a certificate (for example from a commercial certificate authority) and the organisation cannot use Artifact Signing, signtool on the runner does the same job. The certificate has to reach the runner, which is the risk, so the file lives only in an encrypted secret, is written to the runner’s temporary folder, and is deleted immediately.

      - name: Sign with SignTool
        env:
          PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}
          PFX_PASSWORD: ${{ secrets.SIGNING_PFX_PASSWORD }}
        run: |
          $pfx = Join-Path $env:RUNNER_TEMP "signing.pfx"
          [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_BASE64))
          try {
            Get-ChildItem unsigned -Recurse -Filter *.msix | ForEach-Object {
              signtool sign /fd SHA256 /f $pfx /p $env:PFX_PASSWORD `
                /tr http://timestamp.digicert.com /td SHA256 $_.FullName
            }
          }
          finally {
            Remove-Item $pfx -Force -ErrorAction SilentlyContinue
          }

Always timestamp (/tr with /td), so the signature stays valid after the certificate expires. The WinUI article on Learn uses the same idea (a base 64 secret decoded to a file, then removed) for a test certificate. Secrets are masked in the logs, but a command that prints its own arguments can still leak a derived value, so keep the password in an environment variable and never in the command line of a step that echoes it.

Uploading Artifacts and Creating a GitHub Release

Artifacts uploaded with actions/upload-artifact live as long as the retention period and are meant for passing files between jobs or for a quick download. A GitHub Release is the durable, public home of a version and is the place a WinGet manifest or an App Installer file can point to. The release job runs last in the build part of the pipeline, after every platform has been published and signed.

name: release

on:
  push:
    tags: ['v*']

permissions:
  contents: read

jobs:
  version:
    runs-on: ubuntu-latest
    outputs:
      value: ${{ steps.v.outputs.value }}
    steps:
      - id: v
        env:
          TAG: ${{ github.ref_name }}
        run: echo "value=${TAG#v}" >> "$GITHUB_OUTPUT"

  publish-desktop:
    needs: version
    uses: ./.github/workflows/publish-desktop.yml
    with:
      version: ${{ needs.version.outputs.value }}

  # package-msix and sign (previous sections) and package-store, winget and store (next sections) go here

  github-release:
    needs: [version, publish-desktop, sign]
    runs-on: windows-2025
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7

      - name: Download the zipped desktop builds
        uses: actions/download-artifact@v8
        with:
          pattern: bookshelf-desktop-*
          path: dist
          merge-multiple: true

      - name: Download the signed packages
        uses: actions/download-artifact@v8
        with:
          name: msix-signed
          path: dist

      - name: Generate the .appinstaller
        env:
          VERSION: ${{ needs.version.outputs.value }}
          REPO: ${{ github.repository }}
          TAG: ${{ github.ref_name }}
          PUBLISHER: ${{ vars.MSIX_PUBLISHER }}
        run: ./.github/scripts/new-appinstaller.ps1 -Version $env:VERSION -Repo $env:REPO -Tag $env:TAG -Publisher $env:PUBLISHER -OutDir dist

      - name: Create the release
        uses: softprops/action-gh-release@v3
        with:
          files: |
            dist/*.zip
            dist/*.msix
            dist/*.appinstaller
          generate_release_notes: true
          draft: false

The workflow publish-desktop.yml from Workflow 2 is called as a reusable workflow, so its artifacts belong to the same run. Each artifact holds files with distinct names (Bookshelf.Desktop-win-x64.zip, Bookshelf.Desktop-win-arm64.zip, Bookshelf.WinUI_1.4.0.0_x64.msix, Bookshelf.WinUI_1.4.0.0_arm64.msix), so the downloads land side by side in dist without overwriting each other and the release globs match them. The version job strips the v prefix from the tag in one place; every later job reads it from the job output, so the tag v1.4.0 yields a package version 1.4.0.0 and a WinGet version 1.4.0 consistently. Only the release job holds contents: write, the one permission that can modify the repository’s releases.

Generating the .appinstaller

A web-hosted App Installer file tells Windows where the package lives and when to check for a new version. The file is generated, not hand-edited, because its Version attributes must match the package that was just built. A MainPackage names one architecture, so the script writes one file per platform (Bookshelf-x64.appinstaller and Bookshelf-arm64.appinstaller), each pointing at the package of that platform. It uses the 2021 schema, the one that supports ShowPrompt, UpdateBlocksActivation and HoursBetweenUpdateChecks (see Distribution Channels).

param(
  [Parameter(Mandatory)][string]$Version,
  [Parameter(Mandatory)][string]$Repo,
  [Parameter(Mandatory)][string]$Tag,
  [Parameter(Mandatory)][string]$Publisher,
  [Parameter(Mandatory)][string]$OutDir,
  [string[]]$Platforms = @('x64', 'arm64')
)

$base = "https://github.com/$Repo/releases/download/$Tag"
$pkgVersion = "$Version.0"

foreach ($platform in $Platforms) {
  $fileName = "Bookshelf-$platform.appinstaller"
  $xml = @"
<?xml version="1.0" encoding="utf-8"?>
<AppInstaller xmlns="http://schemas.microsoft.com/appx/appinstaller/2021"
              Version="$pkgVersion"
              Uri="https://github.com/$Repo/releases/latest/download/$fileName">
  <MainPackage Name="Example.BookshelfDesktop"
               Publisher="$Publisher"
               Version="$pkgVersion"
               ProcessorArchitecture="$platform"
               Uri="$base/Bookshelf.WinUI_${pkgVersion}_$platform.msix" />
  <UpdateSettings>
    <OnLaunch HoursBetweenUpdateChecks="12" ShowPrompt="true" UpdateBlocksActivation="false" />
  </UpdateSettings>
</AppInstaller>
"@
  Set-Content -Path (Join-Path $OutDir $fileName) -Value $xml -Encoding utf8
}

Three constraints from the overview apply. The ms-appinstaller: URI protocol has been disabled by default since December 2023, so the web page or README links directly to the .appinstaller file and the user double-clicks it. The file supports the https, http and smb protocols. And because GitHub serves release assets from a redirect, test the whole path with a clean machine, not only the URL in a browser. The Publisher value is read from a repository variable holding the subject of your signing certificate (for example CN=Example Publisher); it must equal the manifest publisher, which is why it is stored once, not typed in the script.

Visual Studio can also generate an App Installer file during packaging, but it writes the 2017/2 schema by default, which silently ignores ShowPrompt, UpdateBlocksActivation and HoursBetweenUpdateChecks; generating the file in the script keeps the schema under your control.

Updating WinGet with wingetcreate

WinGet lists a package through a manifest in the community repository winget-pkgs. The first version of a package is created once by hand (wingetcreate new, which can also open the pull request) and reviewed by the repository maintainers. Every later version is only a changed installer URL, version and hash, which is exactly what wingetcreate update automates.

  winget:
    needs: [version, github-release]
    runs-on: windows-2025
    steps:
      - name: Install wingetcreate
        run: Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe

      - name: Update the manifest and open a pull request
        env:
          WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_PAT }}
          VERSION: ${{ needs.version.outputs.value }}
          REPO: ${{ github.repository }}
          TAG: ${{ github.ref_name }}
        run: >
          ./wingetcreate.exe update Example.BookshelfDesktop
          -u "https://github.com/$env:REPO/releases/download/$env:TAG/Bookshelf.Desktop-win-x64.zip|x64"
          "https://github.com/$env:REPO/releases/download/$env:TAG/Bookshelf.Desktop-win-arm64.zip|arm64"
          -v $env:VERSION
          --submit

The tool installs with winget install wingetcreate, or, as on the runner above, as a standalone executable from https://aka.ms/wingetcreate/latest. -u takes the installer URLs, -v the version and --submit submits the change; for CI the token is a personal access token with the repo permission, supplied in the WINGET_CREATE_GITHUB_TOKEN environment variable and stored as a secret. A |x64 or |arm64 suffix on a URL states the installer architecture explicitly, and the number of URLs must match the number of installers in the existing manifest (here one zip per architecture). Before the first automated run, validate a manifest locally with winget validate and install it with winget install --manifest <folder> after winget settings --enable LocalManifestFiles. Each submitted version is a pull request that goes through the repository’s validation pipeline, so a WinGet release lags the GitHub Release by the review time.

Publishing to the Microsoft Store

The Microsoft Store Developer CLI (msstore) calls the Partner Center APIs to upload a package and submit it. Three prerequisites come from the documentation, and each is a one-time manual step: a Partner Center developer account with an associated Microsoft Entra tenant; the app already created by reserving its name; and the first submission completed through Partner Center. The CLI cannot create an app or complete the first publication, and update operations are documented as supported for free products only. A Microsoft Entra app registration is added to Partner Center with the Manager role, and its identifiers become four secrets.

  package-store:
    needs: version
    runs-on: windows-2025-vs2026
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x

      - uses: microsoft/setup-msbuild@v3

      - name: Stamp the package version
        env:
          VERSION: ${{ needs.version.outputs.value }}
        run: |
          $path = Resolve-Path src/Bookshelf.WinUI/Package.appxmanifest
          [xml]$manifest = Get-Content $path
          $manifest.Package.Identity.Version = "$env:VERSION.0"
          $manifest.Save($path.Path)

      - name: Build the Store upload
        run: >
          msbuild src/Bookshelf.WinUI/Bookshelf.WinUI.csproj
          /restore
          /p:Configuration=Release
          /p:Platform=x64
          /p:GenerateAppxPackageOnBuild=true
          /p:UapAppxPackageBuildMode=StoreUpload
          /p:AppxBundle=Always
          /p:AppxBundlePlatforms="x64|arm64"
          /p:AppxPackageSigningEnabled=false
          /p:AppxPackageDir="${{ github.workspace }}\StorePackages\"

      - uses: actions/upload-artifact@v7
        with:
          name: msixupload
          path: StorePackages/**/*.msixupload
          if-no-files-found: error

  store:
    needs: [package-store]
    runs-on: windows-2025
    environment: store
    steps:
      - name: Download the Store upload
        uses: actions/download-artifact@v8
        with:
          name: msixupload
          path: store

      - name: Setup the Microsoft Store Developer CLI
        uses: microsoft/microsoft-store-apppublisher@v1

      - name: Configure credentials
        env:
          PC_TENANT_ID: ${{ secrets.PARTNER_CENTER_TENANT_ID }}
          PC_SELLER_ID: ${{ secrets.PARTNER_CENTER_SELLER_ID }}
          PC_CLIENT_ID: ${{ secrets.PARTNER_CENTER_CLIENT_ID }}
          PC_CLIENT_SECRET: ${{ secrets.PARTNER_CENTER_CLIENT_SECRET }}
        run: >
          msstore reconfigure
          --tenantId $env:PC_TENANT_ID
          --sellerId $env:PC_SELLER_ID
          --clientId $env:PC_CLIENT_ID
          --clientSecret $env:PC_CLIENT_SECRET

      - name: Publish
        env:
          STORE_PRODUCT_ID: ${{ vars.STORE_PRODUCT_ID }}
        run: |
          $upload = (Get-ChildItem store -Recurse -Filter *.msixupload | Select-Object -First 1).FullName
          msstore publish $upload -id $env:STORE_PRODUCT_ID

The relevant options of the CLI:

Command or option Meaning

msstore reconfigure

Stores tenant, seller and client identifiers plus a client secret, or a certificate by thumbprint or file path.

msstore publish <path>

Uploads and submits the package found at the path.

-i, --inputFile

Explicit .msix or .msixupload file, instead of letting the CLI search.

-id, --appId

Application id; only needed when the project was not initialised with msstore init.

-nc, --noCommit

Uploads the package but keeps the submission in draft.

-f, --flightId

Publishes to a package flight (a test ring) instead of production.

-prp, --packageRolloutPercentage

Starts a gradual rollout at the given percentage (0 to 100).

msstore submission status / poll / publish

Inspects, waits for or commits an existing submission.

Note the documented ordering rule: publish recreates the draft from the last published submission, so metadata changes must be made after the upload, not before. The package-store job builds the Store variant of the MSBuild command shown in the WinUI article (StoreUpload, AppxBundle=Always, AppxBundlePlatforms, AppxPackageSigningEnabled=false) and uploads the .msixupload as the msixupload artifact; the Store signs the submission itself, so the Store path needs no signing job. The four Partner Center values reach msstore reconfigure through env:, like every other secret on this page, rather than being pasted into the command text.

Secrets and Permissions Checklist

Secret or variable Holds Used by

AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID (secrets)

Identifiers of the Entra app registration trusted through OIDC. No password.

azure/login

SIGNING_ENDPOINT, SIGNING_ACCOUNT, SIGNING_PROFILE (variables)

Names of the signing resources.

Artifact Signing action

MSIX_PUBLISHER (variable)

Subject of the signing certificate.

.appinstaller script

SIGNING_PFX_BASE64, SIGNING_PFX_PASSWORD (secrets)

Only for the SignTool route.

SignTool step

WINGET_PAT (secret)

Personal access token with the repo permission for the winget-pkgs fork.

wingetcreate

PARTNER_CENTER_TENANT_ID, PARTNER_CENTER_SELLER_ID, PARTNER_CENTER_CLIENT_ID, PARTNER_CENTER_CLIENT_SECRET (secrets)

Partner Center API credentials.

msstore reconfigure

Pitfalls

  • Publisher mismatch. The signing certificate subject must equal the manifest publisher. A pipeline that signs successfully can still produce a package that will not install; install the signed artifact on a clean machine as part of the release checklist.

  • Secrets in pull requests from forks. GitHub withholds secrets from fork pull requests, so keep the build job free of secrets and gate everything that signs or publishes on a tag or a protected environment.

  • Script injection. Pass github.* and inputs.* values through env: and read them as environment variables; never paste an expression into a script body.

  • Moving -latest labels. A windows-latest job can change image without a commit. Pin the release workflow to a concrete image and let the pull-request workflow float.

  • Tag-only triggers hide mistakes. The release workflow runs rarely. Add a workflow_dispatch trigger and a dry-run mode (for example --noCommit for the Store) so the path is exercised before the real tag.

  • Artifact name collisions. In upload-artifact v4 and later names must be unique within a run, which is why the matrix jobs include the platform or RID in the name.

  • Long-lived passwords. Prefer OIDC to a stored client secret; the Partner Center and WinGet tokens are the remaining long-lived credentials, so rotate them and scope them as narrowly as the services allow.

Summary

  • A desktop pipeline is three layers: a secret-free verification workflow for every pull request, a tag-triggered workflow that builds once, signs once and publishes the same files, and per-channel jobs for GitHub Releases, WinGet and the Store.

  • Use windows-latest for checks and a pinned image for releases; pin every action to a verified major, or to a commit SHA, and let dependabot propose updates.

  • Call dotnet publish per RID for WPF and WinForms and MSBuild with the Appx properties for WinUI 3 MSIX; do not rely on the outdated MsixPackaging@1 task.

  • Sign with Artifact Signing through azure/login and OIDC so no key or password is stored; use SignTool with a secret certificate only when you must, and always timestamp.

  • Keep credentials in ${{ secrets.NAME }}, pass untrusted values through env:, narrow permissions: per job and protect the signing and Store jobs with an environment that needs approval.

  • Automate WinGet updates with wingetcreate update and Store updates with msstore publish, after the first version of each has been created by hand.