Part of the Shipping Large Rust Apps series — start with the map.
You have a Windows MSI. You built it earlier in this series with cargo-wix (that is The Windows MSI), and the one-tag workflow already attaches it to a GitHub Release every time you push a v* tag. Now you want a stranger on Windows to type winget install SecurityRonin.sqlite4n6 and have it just work.
This post gets you there from zero. It assumes you have never submitted to winget and have no idea what a manifest, a PackageIdentifier, a ProductCode, or an UpgradeCode is. We will define every one of those, hand-author the three files your first submission needs, walk the pull request by hand, then wire up the action that does it automatically for every release after.
There is one lesson that shapes everything else, so read it before anything: the automation cannot create a new package. It can only bump one that already exists. Your first version is manual. Internalize that and the rest is mechanical.
What winget actually is
winget is the Windows Package Manager — a command-line installer that ships with modern Windows. A user types winget install Microsoft.PowerToys and winget downloads the installer, runs it silently, and tracks the version so winget upgrade works later. It is the Windows answer to brew or apt.
Where does winget learn that Microsoft.PowerToys exists, where to download it, and what its SHA256 should be? From a giant public Git repository on GitHub: microsoft/winget-pkgs. Every package winget knows about is a folder of YAML files in that repo. To add your tool, you add files to that repo — by opening a pull request, exactly like contributing to any open-source project. Microsoft's bots and maintainers validate and merge it, and from then on your package is in the index that every winget client queries.
So "publishing to winget" means: get the right YAML files merged into microsoft/winget-pkgs. That is the whole game.
A package is three YAML manifests
winget does not describe a package in one file. It uses three, and they live together in one folder:
-
The version manifest — the smallest. It names the package and points at the other two. Filename:
YourPublisher.YourTool.yaml. -
The installer manifest — the meat. Architecture, installer type, the download URL, the SHA256, and (for MSIs) the
ProductCode. Filename:YourPublisher.YourTool.installer.yaml. -
The default-locale manifest — the human-readable metadata: publisher name, package name, description, license, homepage. Filename:
YourPublisher.YourTool.locale.en-US.yaml.
All three sit in a path built from the identifier and version:
manifests/<first-letter>/<Publisher>/<Tool>/<Version>/
For a package identified as SecurityRonin.sqlite4n6 at version 0.1.0, that path is:
manifests/s/SecurityRonin/sqlite4n6/0.1.0/
The <first-letter> is the lowercased first letter of the publisher. Every new version gets its own version folder — 0.1.0/, 0.2.0/, … 0.10.1/ — each with its own three files.
The running example in this post is real: sqlite4n6, our read-only SQLite forensic CLI, published by SecurityRonin as SecurityRonin.sqlite4n6. (The package name and the repo name — sqlite-forensic — do not have to match; the identifier is its own namespace.) Every manifest, hash, and GUID below is from its actual first submission, and you can read the merged first-submission PR in winget-pkgs. Substitute your own values everywhere.
The PackageIdentifier
The PackageIdentifier is the unique name a user types: SecurityRonin.sqlite4n6. The convention is Publisher.PackageName — PascalCase is common, but keep your tool's natural casing (ours is lowercase, matching the crate and binary name) — and it must be globally unique across all of winget-pkgs. It is also the folder structure, the filename prefix, and the value the auto-update action keys off later. Pick it once and never change it — changing it later means a brand-new package, not a rename.
The four traps, named up front
The map flagged four traps for this leg. Here they are, so you know what each later step is defending against:
-
The action cannot create a package.
winget-releaser(the GitHub Action) only updates a package that already exists in winget-pkgs. Your first version is a manual PR. Every version after that is automated. -
Make the winget release job
continue-on-error: trueuntil that first PR merges. Before the package exists, the action fails — correctly, there is nothing to bump. Without the flag, that failure reds your whole release for no real reason. -
UpgradeCode stable, ProductCode fresh. Two GUIDs in your MSI. winget decides "this is an upgrade of the thing already installed" by matching the
UpgradeCode, so it must stay identical across every version. TheProductCodeidentifies one specific build and changes every time you rebuild — so you must read it out of each new MSI and put the fresh value in that version's installer manifest. -
The action reads the
.msioff your release — attach it, or it dies with an empty--urls.winget-releasermatches yourinstallers-regex(\.msi$) against the assets on the GitHub Release for that tag, then feeds the matched download URLs towingetcreate. If your release publishes only the portable.zipand not the built.msi, the regex matches nothing and the action fails witherror: a value is required for '--urls' but none was supplied— even though the package exists in winget-pkgs and CI built the MSI. Building it isn't publishing it: the release's upload glob has to include*.msi, not just*.zip. (Lived case: a release whosefiles:listed*.tar.gzand*.zipbut not*.msi— green build, silently no winget update.)
We will hit them in order.
Sign the MSI before you submit it
There is a step that belongs before the first PR, because it changes how both winget's moderators and the end user's machine treat your installer: Authenticode-sign the MSI. An unsigned .exe/.msi has no publisher identity, so Windows SmartScreen shows the blue "Windows protected your PC — unknown publisher" warning on first run, and winget's own moderation looks harder at an installer from a publisher it cannot verify. A signed installer from a validated organization shows a verified publisher instead, and the first submission moves more smoothly.
Authenticode is that identity. The old way to get a cert was an EV code-signing certificate on a USB HSM token — expensive, physically mailed, and impossible to plug into CI. The new way is Azure Trusted Signing (Microsoft rebranded it Azure Artifact Signing mid-flight, which matters — see the RBAC gotcha below): about $9.99/month, cloud-HSM-backed, short-lived certs, and CI-native via OIDC — no secret to store, no token to plug in.
One-time setup
- Register the resource provider and deploy the account:
az provider register --namespace Microsoft.CodeSigning
az group create --name signing --location northeurope
az deployment group create -g signing --template-uri \
"https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/quickstarts/microsoft.codesigning/codesigning-create-account/azuredeploy.json" \
--parameters accountName=securityronin skuName=Basic location=northeurope
Region gotcha: a brand-new tenant is blocked from capacity-constrained regions. West Europe rejected the account with RequestDisallowedByAzure: "The selected region is currently not accepting new customers." Deploy to North Europe (or a US region). Known-good: N.Europe, W.Europe, E.US, W.Central US, W.US3.
Public Trust identity validation. In the Trusted Signing account → Identity validations → Organization → Public — validated against Dun & Bradstreet. You must be a US / CA / EU / UK organization with a D-U-N-S number. Then create a Public Trust certificate profile.
Assign the RBAC roles — and mind the rebrand. The role names are
Artifact Signing …, NOT "Trusted Signing …" — the rebrand desynced the docs from the actual role-definition strings. You wantArtifact Signing Certificate Profile Signeron the CI identity (andArtifact Signing Identity Verifierfor the human doing the validation). Discover the exact strings if in doubt:
az role definition list --query "[?contains(roleName,'Signing')].roleName" -o tsv
-
Create the CI identity as an Entra app + federated (OIDC) credential — subject
repo:SecurityRonin/sqlite-forensic:ref:refs/tags/v[0-9]*, holding the Certificate Profile Signer role. There's no client secret to rotate; theAZURE_TENANT_ID/AZURE_CLIENT_IDyou store as "secrets" are just non-sensitive IDs.
The CI signing step
Sign on the Windows runner, after cargo build produces the .exe but before you feed it to cargo-wix — sign the bytes that ship, then package them. Then sign the built .msi as its own step, so the installer clears SmartScreen too, not just the exes inside it.
permissions:
id-token: write # OIDC → Azure (no stored client secret)
contents: write
# … after the .exe is built, before cargo-wix builds the MSI:
# OIDC login FIRST. The signing action's DefaultAzureCredential does NOT do the
# GitHub-OIDC exchange itself — azure/login trades the id-token for an az session
# the signer then rides on.
- uses: azure/login@<sha> # v3.0.0 — SHA-pin
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/trusted-signing-action@<sha> # SHA-pin
with: # NO azure-* auth inputs — auth = the login session
endpoint: https://neu.codesigning.azure.net # North Europe — region-specific!
trusted-signing-account-name: securityronin
certificate-profile-name: securityronin-public
files-folder: target/${{ matrix.target }}/release
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
Run the same signing action a second time against the built .msi (filter msi).
The three gotchas that will cost you an afternoon:
-
azure/loginbefore the signer is mandatory — it's the #1 CI failure. Without it, the action falls through toAzureCliCredentialand diesPlease run 'az login'— a maddeningly misleading error, because it's a missing step, not a local mistake. Runazure/loginfirst, drop theazure-*inputs from the signing action, and remember to passAZURE_SUBSCRIPTION_ID. -
The endpoint is region-specific and must match the account's region —
neu/weu/eus/ ….codesigning.azure.net. Wrong endpoint = a confusing auth failure that looks like a permissions problem. -
Sign before you package. Sign the
.exe, then build the MSI, and sign the MSI as its own step. Signing the wrapper doesn't sign what's inside it.
With the MSI signed, the SHA256 you read out for the manifest (next section) is the hash of the signed installer — so sign first, then hash, then submit.
Part 1 — The manual first submission
Step 1: Get the SHA256 and the ProductCode from your MSI
Your installer manifest needs two values pulled out of the actual MSI file: its SHA256 hash, and its ProductCode.
The SHA256 is straightforward. On Windows PowerShell:
Get-FileHash .\sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi -Algorithm SHA256
For this MSI that prints 3BFD608862C6305BF9401930ED9608CD50B34CBFC0DAD18484116E0AE6DE93A7. Copy the hash. winget wants it uppercase; PowerShell already gives it to you that way.
The ProductCode is a GUID baked into the MSI. An MSI stores it in a Property table inside the file, under the property name ProductCode. Two ways to read it:
With msitools (the msiinfo command, available on Linux/macOS or via MSYS2 on Windows):
msiinfo export sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi Property | grep -E '^Product(Code|Version)|^UpgradeCode'
That dumps the Property table; for this MSI:
ProductCode {0EB6897B-0234-46DC-8810-EACA6E0BFDB9}
ProductVersion 0.1.0
UpgradeCode {070DCA3F-F901-4736-9C5D-12F7AA00F064}
Or natively in PowerShell, using the Windows Installer COM object:
$installer = New-Object -ComObject WindowsInstaller.Installer
$db = $installer.OpenDatabase(".\sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi", 0)
$view = $db.OpenView("SELECT Value FROM Property WHERE Property='ProductCode'")
$view.Execute()
$view.Fetch().StringData(1)
Either way you get a GUID in braces — {0EB6897B-0234-46DC-8810-EACA6E0BFDB9} for this build. That is your ProductCode. Write it down. It is wrong for the next build — you re-read it every release. (sqlite4n6 is living proof of both halves: ten versions later, at 0.10.1, the ProductCode has become {2F509EB7-EBBA-423D-958F-D399AAE971FA} while the UpgradeCode is still {070DCA3F-F901-4736-9C5D-12F7AA00F064} — fresh every build, stable forever, respectively.)
Where do these GUIDs come from? They are set in your
wix/main.wxs, the WiX source The Windows MSI post covers. TheUpgradeCodeis an attribute you hardcode once and never touch. TheProductCodeis theProduct/@Id— and if you set it to*(the WiX convention for "autogenerate"), WiX mints a new one on every build. That is exactly why it is fresh each release.
Step 2: Write the version manifest
Create SecurityRonin.sqlite4n6.yaml:
# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json
PackageIdentifier: SecurityRonin.sqlite4n6
PackageVersion: 0.1.0
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.12.0
Every field:
- The
# yaml-language-servercomment is optional but worth keeping — editors with a YAML language server validate the file against the schema as you type. -
PackageIdentifier— the unique name, exactly as it appears in the folder path and the other two files. -
PackageVersion—0.1.0. This must match the version folder name and the version in the other manifests. -
DefaultLocale—en-US, telling winget which locale manifest is the fallback. It must match the locale of your locale file. -
ManifestType—version. This is what marks this file as the version manifest. -
ManifestVersion— the schema version of the manifest format itself (not your app's version). Use a current one —1.12.0here; check the winget-pkgs manifest schemas for the latest.
Step 3: Write the installer manifest
Create SecurityRonin.sqlite4n6.installer.yaml:
# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json
PackageIdentifier: SecurityRonin.sqlite4n6
PackageVersion: 0.1.0
InstallerLocale: en-US
InstallerType: wix
Scope: machine
InstallModes:
- interactive
- silent
- silentWithProgress
Dependencies:
PackageDependencies:
- PackageIdentifier: Microsoft.VCRedist.2015+.x64
ProductCode: '{0EB6897B-0234-46DC-8810-EACA6E0BFDB9}'
ReleaseDate: 2026-06-18
AppsAndFeaturesEntries:
- Publisher: Security Ronin
ProductCode: '{0EB6897B-0234-46DC-8810-EACA6E0BFDB9}'
UpgradeCode: '{070DCA3F-F901-4736-9C5D-12F7AA00F064}'
InstallationMetadata:
DefaultInstallLocation: '%ProgramFiles%\sqlite4n6\bin'
Installers:
- Architecture: x64
InstallerUrl: https://github.com/SecurityRonin/sqlite-forensic/releases/download/v0.1.0/sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi
InstallerSha256: 3BFD608862C6305BF9401930ED9608CD50B34CBFC0DAD18484116E0AE6DE93A7
ManifestType: installer
ManifestVersion: 1.12.0
Field by field:
-
PackageIdentifier,PackageVersion— same values as the version manifest. They tie the three files together. -
InstallerType—wixfor an MSI built by WiX (whichcargo-wixis). The plain valuemsialso works;wixis the more specific type for a WiX-authored MSI and is what winget recommends for these. Both are validInstallerTypevalues. -
Scope: machine— this MSI installs for all users under%ProgramFiles%, not per-user. Declare it so winget shows and handles the install correctly. -
InstallModes— which of winget's install experiences the MSI supports. An MSI built bycargo-wixsupports all three; listing them lets the user pick--interactiveor (the default) silent. -
Dependencies— packages winget should install first. A Rust binary built with the MSVC toolchain links the VC++ runtime, so declareMicrosoft.VCRedist.2015+.x64rather than hoping the target machine has it. This is the difference between "works on most machines" and "works on a fresh VM". -
ProductCode— the GUID from Step 1, in braces, quoted because YAML otherwise tries to read{…}as a map. This is the value that changes every release. -
ReleaseDate— the release's date, straight off the GitHub Release. -
AppsAndFeaturesEntries— how the installed app appears in Windows' Apps & Features list: display publisher, and theProductCode/UpgradeCodepair that lets winget match an already-installed copy to this package. This is the one place theUpgradeCodeappears in a manifest — winget uses it to recognize "this installed thing is an older version of that package" even when every version'sProductCodediffers. Your job is still to keep it stable in thewxs(see the box in Step 1). -
InstallationMetadata.DefaultInstallLocation— where the MSI puts the files; lets winget find the install for repair/portable-alias purposes. -
Installers— a list, one entry per architecture you ship. Most Rust CLIs ship one x64 MSI, so one entry.-
Architecture—x64forx86_64. (Usearm64if you also ship an ARM MSI;x86for 32-bit.) -
InstallerUrl— the direct download URL of the MSI asset on your GitHub Release. This is the URL the one-tag workflow produces —…/releases/download/<tag>/<msi-filename>. It must be a stable, public, direct link, not a redirect page. -
InstallerSha256— the hash from Step 1. winget refuses to install if the downloaded file's hash does not match this, which is how it guarantees the binary is the one you submitted.
-
-
ManifestType—installer. -
ManifestVersion— same schema version as the other two files.
Strictly, only the identifier/version/type/URL/hash core is required — but the middle block (Scope, InstallModes, Dependencies, AppsAndFeaturesEntries, InstallationMetadata) is what makes the package behave like a first-class citizen on a stranger's machine, so write it in the first submission and the auto-update tooling will carry it forward.
Step 4: Write the default-locale manifest
Create SecurityRonin.sqlite4n6.locale.en-US.yaml:
# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json
PackageIdentifier: SecurityRonin.sqlite4n6
PackageVersion: 0.1.0
PackageLocale: en-US
Publisher: SecurityRonin
PublisherUrl: https://github.com/SecurityRonin
PublisherSupportUrl: https://github.com/SecurityRonin/sqlite-forensic/issues
PackageName: sqlite4n6
PackageUrl: https://github.com/SecurityRonin/sqlite-forensic
License: Apache-2.0
LicenseUrl: https://github.com/SecurityRonin/sqlite-forensic/blob/HEAD/LICENSE
Copyright: Copyright (c) SecurityRonin
ShortDescription: Read-only SQLite forensic CLI — carve deleted records, grade anomalies
Description: |-
sqlite4n6 is a read-only SQLite forensic CLI. It carves deleted records out of
a database's free (unallocated) space — freelist pages, in-page free blocks,
dropped-table pages, and an uncheckpointed WAL overlay — recovering rows a live
query cannot, and grades forensically-notable anomalies into severity-ranked
findings. It opens the evidence file read-only and never writes the file or its
sidecars.
Moniker: sqlite4n6
Tags:
- carving
- cli
- data-recovery
- deleted-records
- dfir
- forensics
- sqlite
- wal
ReleaseNotes: 'Full Changelog: https://github.com/SecurityRonin/sqlite-forensic/commits/v0.1.0'
ReleaseNotesUrl: https://github.com/SecurityRonin/sqlite-forensic/releases/tag/v0.1.0
ManifestType: defaultLocale
ManifestVersion: 1.12.0
The fields:
-
PackageIdentifier,PackageVersion— same as the others. -
PackageLocale—en-US, and it must equal theDefaultLocalefrom the version manifest. -
Publisher— the human-readable publisher name (the legal/brand name), distinct from thePackageIdentifierprefix. -
PublisherUrl— the publisher's site or GitHub org. Optional but expected. -
PackageName— the display name users see inwinget searchandwinget show. -
PackageUrl— the project homepage. -
License— the SPDX license identifier (Apache-2.0,MIT, etc.). -
LicenseUrl— link to the license text. -
ShortDescription— one sentence. This shows up in search results, so make it say what the tool does. -
Description— the long-form version, shown bywinget show. Say what the tool actually does, concretely. -
PublisherSupportUrl— where users report problems; the repo's issues page is the natural value. -
Copyright— a one-line copyright string. -
Moniker— an optional short alias users can install by (winget install sqlite4n6). Not unique like the identifier; treat it as a convenience. -
Tags— search keywords. -
ReleaseNotes/ReleaseNotesUrl— per-version notes; the auto-update tooling fills these from the GitHub Release on every bump. -
ManifestType—defaultLocale. -
ManifestVersion— same schema version.
Publisher, PackageName, License, ShortDescription are the required ones; the rest are strongly recommended and will make your winget show output look complete.
Step 5: Validate locally before you submit
You have three files. Before you fork anything, check that winget itself accepts them. On a Windows machine with winget installed, point winget validate at the folder:
winget validate --manifest .\manifests\s\SecurityRonin\sqlite4n6\0.1.0\
It parses all three files and reports schema errors — a wrong ManifestType, a mismatched version, a missing required field. Fix until it says the manifests are valid.
Then test that the package actually installs from your manifests, using a local install:
winget install --manifest .\manifests\s\SecurityRonin\sqlite4n6\0.1.0\
This is the real test. winget reads your installer manifest, downloads the MSI from InstallerUrl, checks the SHA256 against InstallerSha256, and runs the install silently. If the URL is wrong, the hash is stale, or the MSI is broken, you find out here — on your machine, before a single maintainer looks at it.
Checkpoint. Before moving on: winget validate passes, and winget install --manifest installs and runs your tool. If both hold, your manifests are correct and your URL/hash are live. Now, and only now, do you open the PR.
Step 6: Fork, place the files, open the PR
The submission is an ordinary GitHub pull request to microsoft/winget-pkgs.
- Fork
microsoft/winget-pkgsto your account or org. (The auto-update action later expects a fork to exist; forking now does double duty.) - Clone your fork, create a branch, and add your three files at the exact path:
manifests/s/SecurityRonin/sqlite4n6/0.1.0/SecurityRonin.sqlite4n6.yaml
manifests/s/SecurityRonin/sqlite4n6/0.1.0/SecurityRonin.sqlite4n6.installer.yaml
manifests/s/SecurityRonin/sqlite4n6/0.1.0/SecurityRonin.sqlite4n6.locale.en-US.yaml
- Commit, push to your fork, and open a PR against
microsoft/winget-pkgs.
What happens next is automated on Microsoft's side. A validation bot runs the same schema checks (and an install/sandbox test), labels the PR, and either flags problems for you to fix or clears it for a maintainer. A first submission for a new package gets a closer human look than later bumps, so expect some back-and-forth and answer it promptly. When it merges, your package exists in winget-pkgs.
Checkpoint. The PR is merged — for sqlite4n6 that was winget-pkgs PR #390308. SecurityRonin.sqlite4n6 is now a real package. From here on, you never hand-author manifests again — the action does it.
Part 2 — Auto-updates for every release after
Now that the package exists, the winget-releaser action can bump it. It runs in your project repo (not winget-pkgs), reacts to a published GitHub Release, reads the MSI asset, and opens the bump PR to winget-pkgs for you — recomputing the SHA256 and pulling the fresh ProductCode itself.
This job belongs in the same release pipeline as everything else — it is one more fan-out target from the one-tag workflow. Add it as a job that runs after the GitHub Release is published:
This is the live job from sqlite4n6's release.yml, verbatim:
winget:
needs: release
runs-on: windows-latest
# First-time winget submission requires a manual PR; continue-on-error until registered
continue-on-error: true
steps:
- uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: SecurityRonin.sqlite4n6
installers-regex: '\.msi$'
token: ${{ secrets.WINGET_TOKEN }}
fork-user: securityronin-bot
What each piece does:
-
needs: release— this job waits for the job that creates the GitHub Release and attaches the MSI. The action reads the MSI off the published Release, so the Release must exist first. (The action only works on a published, non-draft release, because the asset has to be publicly downloadable.) -
continue-on-error: true— the second trap. Until your first manual PR has merged, the package does not exist, so the action fails. This flag stops that expected failure from failing the whole release run. The moment your bootstrap PR is merged, delete this line so a real winget failure becomes visible again. Leaving it forever means a genuinely broken winget bump passes silently. -
identifier— thePackageIdentifier, exactly as registered. This is how the action finds the existing package to bump. -
installers-regex— which Release assets are the installers. The default matches several installer extensions; narrowing it to\.msi$makes sure it grabs your MSI and nothing else. -
token— a Personal Access Token, stored as a repo or org secret. The action needs it to push a branch to your winget-pkgs fork and open the PR. It must be a classic PAT withpublic_reposcope — the action does not support fine-grained tokens. (Keep this token as an organization secret, like the rest of your publish tokens, per the secrets lesson in the map.) -
fork-user— the account holding the winget-pkgs fork the action pushes to. The fleet uses a dedicated bot account (securityronin-bot) so the PR spam lands on a machine identity, not a human's fork; thetokenmust belong to this account.
Under the hood the action uses Komac (a winget manifest tool) to regenerate all three manifests for the new version, with the new URL, the recomputed hash, and the fresh ProductCode read from the new MSI. You do nothing per release except push your v* tag — the same tag that drives every other channel.
The action is pinned to a full commit SHA (with the # v2 comment recording the human-readable version) — the supply-chain discipline from the map applied to one more third-party action. Check its releases when bumping the pin, and let Renovate or Dependabot keep the SHA current.
Verify it actually worked
A merged PR is not proof a user can install your tool. The winget client only sees a new package after the source index it queries is rebuilt and your client pulls it — usually a short wait after merge, plus a winget source update. Verify it for real:
winget source update
winget search SecurityRonin.sqlite4n6
winget install SecurityRonin.sqlite4n6
(These are live commands — the package is real, so you can run them on any Windows machine right now.)
winget search should list your package; winget install should download from your GitHub Release, check the hash, and install. Run the tool to confirm it works. That is a green install, not a green push.
For the next release, the upgrade path is the thing to verify: after the action's bump PR merges, a machine that already has the old version should get the new one with:
winget upgrade SecurityRonin.sqlite4n6
If winget upgrade does not see your new version as an upgrade of the installed one, the usual cause is an UpgradeCode that changed between builds — winget no longer recognizes the new MSI as the same product. That brings us to the recap.
No MSI? The portable-zip route — and the type-flip trap
Everything above assumes an MSI. winget also installs plain zips: our timeglyph shipped its first winget versions as SecurityRonin.timeglyph with nothing but the zip the release matrix already produced. The installer manifest swaps the MSI fields for three lines:
InstallerType: zip
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: timeglyph.exe
PortableCommandAlias: timeglyph
winget downloads the zip, extracts timeglyph.exe, and shims it onto the user's PATH under the alias. No ProductCode, no UpgradeCode, no WiX — if your tool is a single CLI executable, this is the lowest-effort door into winget, and the same manual-first-PR-then-winget-releaser bootstrap applies unchanged.
The trade-offs are real, though: a portable install puts nothing in Apps & Features, runs no installer logic — no Start Menu shortcut, so a companion GUI binary in the zip has no launcher — and (unlike the MSI route) the extracted exe's Authenticode signature is the only publisher identity the user ever sees. timeglyph is outgrowing exactly these limits: its next release ships a signed MSI that installs both binaries and a Start Menu shortcut for its GUI. Which exposes one more trap for the table: winget-releaser bumps versions, it does not change a package's installer type. Moving a package from zip to msi is another one-time manual manifest PR — the same bootstrap ritual as the first submission, once per structural change.
What bit me, and the fix
| What bit me | Why | The fix |
|---|---|---|
| The auto-update action failed on the very first release | The package did not exist yet; the action only bumps existing packages | Hand-author three manifests and open a manual PR to microsoft/winget-pkgs for the first version |
| Every release run went red because of winget | The action fails (correctly) until the first PR merges | Set continue-on-error: true on the winget job; remove it once the bootstrap PR is merged |
winget upgrade stopped recognizing new versions |
The MSI's UpgradeCode changed between builds |
Hardcode the UpgradeCode once in wix/main.wxs and never change it |
Validation rejected a stale ProductCode
|
The ProductCode changes every build; the manifest carried the old one |
Re-read the ProductCode from each new MSI (msiinfo export … Property or the PowerShell COM call); the action does this for you on auto-bumps |
| The action could not open a PR (auth error) | A fine-grained PAT, or one without public_repo
|
Use a classic PAT with public_repo scope, stored as a secret |
winget install failed on hash mismatch |
The InstallerSha256 did not match the actual MSI |
Recompute the SHA256 from the exact file you uploaded; test with winget install --manifest before submitting |
InstallerUrl 404'd during install |
URL pointed at a release page or a moved asset | Use the direct …/releases/download/<tag>/<file>.msi link from the GitHub Release |
The shape of it: one manual PR to bootstrap the package, one continue-on-error flag to survive the gap, one stable UpgradeCode and one fresh ProductCode per build. Past that, your v* tag carries winget along with everything else — the action regenerates the manifests, opens the PR, and a stranger on Windows types winget install SecurityRonin.sqlite4n6 and gets your tool.

Top comments (0)