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.xand 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-vs2026image. -
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.
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 |
|---|---|---|
|
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. |
|
A fixed image; the second name selects the image with Visual Studio 2026. |
Release builds that must be reproducible. |
|
Windows Server 2022. |
Only when a tool needs the older image. |
|
Windows 11 on Arm64. |
Running the tests natively on Arm64. |
Source: GitHub runner images
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 |
|---|---|---|
|
|
Clones the repository (latest release 7.0.1). |
|
|
Installs the .NET SDK (latest release 6.0.0). |
|
|
Puts MSBuild on |
|
|
Stores build outputs for later jobs and for download (7.0.2). |
|
|
Retrieves them in a later job (8.0.2). |
|
|
Signs in to Azure with OpenID Connect, no stored password (3.1.0). |
|
|
Signs files with Artifact Signing (2.0.0). |
|
|
Creates a GitHub Release and attaches files (3.0.3). Community action. |
|
|
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: readreplaces the default token permissions with the minimum. Each job later widens it only where needed. -
concurrencycancels a superseded run of the same branch, which saves runner minutes on a busy pull request. -
--no-restoreand--no-buildmake each step do one thing;dotnet testanddotnet publishwould 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 publishisReleasefor projects targeting .NET 8 or later, butbuildandteststill default toDebug, so the workflow names--configuration Releaseexplicitly 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
Source: dotnet publish command
The options map to the publishing choices described in the page .NET Publishing Options:
| Option | Effect |
|---|---|
|
Publishes for one runtime identifier. The documented advice is to pair it with |
|
Includes the .NET runtime in the output so the target needs no installed runtime. |
|
Packs the app into one platform-specific executable; it implicitly sets self-contained. |
|
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 folder; on a solution the option is rejected, so the workflow publishes the project, not the solution. |
|
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 |
|---|---|
|
Makes the build produce the MSIX package. |
|
Folder that receives the package. |
|
A package for sideloading (signed by you). |
|
One package per platform, or a bundle. A Store upload built in one invocation uses |
|
Leaves the package unsigned so a later job signs it. The Store re-signs a submission itself, so a |
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 over OpenID Connect (recommended)
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
Source: Artifact Signing action
The inputs, as documented in the action’s readme:
| Input | Meaning |
|---|---|
|
Regional endpoint of the signing account; it must match the region of the account and certificate profile. |
|
The two resources that identify the signer. |
|
What to sign: explicit files, or a folder with an extension filter. |
|
|
|
Timestamp server and digest. The readme recommends |
Source: What is Artifact Signing?
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
Source: Using secrets in GitHub Actions
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
}
Source: App Installer file overview
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 |
|---|---|
|
Stores tenant, seller and client identifiers plus a client secret, or a certificate by thumbprint or file path. |
|
Uploads and submits the package found at the path. |
|
Explicit |
|
Application id; only needed when the project was not initialised with |
|
Uploads the package but keeps the submission in draft. |
|
Publishes to a package flight (a test ring) instead of production. |
|
Starts a gradual rollout at the given percentage (0 to 100). |
|
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 |
|---|---|---|
|
Identifiers of the Entra app registration trusted through OIDC. No password. |
|
|
Names of the signing resources. |
Artifact Signing action |
|
Subject of the signing certificate. |
|
|
Only for the SignTool route. |
SignTool step |
|
Personal access token with the |
|
|
Partner Center API credentials. |
|
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.*andinputs.*values throughenv:and read them as environment variables; never paste an expression into a script body. -
Moving
-latestlabels. Awindows-latestjob 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_dispatchtrigger and a dry-run mode (for example--noCommitfor the Store) so the path is exercised before the real tag. -
Artifact name collisions. In
upload-artifactv4 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-latestfor 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 publishper RID for WPF and WinForms and MSBuild with the Appx properties for WinUI 3 MSIX; do not rely on the outdatedMsixPackaging@1task. -
Sign with Artifact Signing through
azure/loginand 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 throughenv:, narrowpermissions:per job and protect the signing and Store jobs with an environment that needs approval. -
Automate WinGet updates with
wingetcreate updateand Store updates withmsstore publish, after the first version of each has been created by hand.