<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Albert Hui</title>
    <description>The latest articles on DEV Community by Albert Hui (@4n6h4x0r).</description>
    <link>https://dev.to/4n6h4x0r</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2605940%2F5e6cde7d-ce9e-44d6-a9b7-11f9c2462a62.jpg</url>
      <title>DEV Community: Albert Hui</title>
      <link>https://dev.to/4n6h4x0r</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/4n6h4x0r"/>
    <language>en</language>
    <item>
      <title>winget From Zero</title>
      <dc:creator>Albert Hui</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:59:39 +0000</pubDate>
      <link>https://dev.to/4n6h4x0r/winget-from-zero-the-manual-first-submission-then-auto-updates-5do7</link>
      <guid>https://dev.to/4n6h4x0r/winget-from-zero-the-manual-first-submission-then-auto-updates-5do7</guid>
      <description>&lt;p&gt;Part of the &lt;em&gt;Shipping Large Rust Apps&lt;/em&gt; series — start with &lt;a href="https://dev.to/4n6h4x0r/the-half-of-rust-nobody-teaches-shipping-56em"&gt;the map&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You have a Windows MSI. You built it earlier in this series with &lt;code&gt;cargo-wix&lt;/code&gt; (that is &lt;em&gt;The Windows MSI&lt;/em&gt;), and the &lt;em&gt;one-tag workflow&lt;/em&gt; already attaches it to a GitHub Release every time you push a &lt;code&gt;v*&lt;/code&gt; tag. Now you want a stranger on Windows to type &lt;code&gt;winget install SecurityRonin.sqlite4n6&lt;/code&gt; and have it just work.&lt;/p&gt;

&lt;p&gt;This post gets you there from zero. It assumes you have never submitted to winget and have no idea what a manifest, a &lt;code&gt;PackageIdentifier&lt;/code&gt;, a &lt;code&gt;ProductCode&lt;/code&gt;, or an &lt;code&gt;UpgradeCode&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;There is one lesson that shapes everything else, so read it before anything: &lt;strong&gt;the automation cannot create a new package. It can only bump one that already exists.&lt;/strong&gt; Your first version is manual. Internalize that and the rest is mechanical.&lt;/p&gt;

&lt;h2&gt;
  
  
  What winget actually is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;winget&lt;/code&gt; is the Windows Package Manager — a command-line installer that ships with modern Windows. A user types &lt;code&gt;winget install Microsoft.PowerToys&lt;/code&gt; and winget downloads the installer, runs it silently, and tracks the version so &lt;code&gt;winget upgrade&lt;/code&gt; works later. It is the Windows answer to &lt;code&gt;brew&lt;/code&gt; or &lt;code&gt;apt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Where does winget learn that &lt;code&gt;Microsoft.PowerToys&lt;/code&gt; exists, where to download it, and what its SHA256 should be? From a giant public Git repository on GitHub: &lt;strong&gt;&lt;code&gt;microsoft/winget-pkgs&lt;/code&gt;&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;So "publishing to winget" means: get the right YAML files merged into &lt;code&gt;microsoft/winget-pkgs&lt;/code&gt;. That is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  A package is three YAML manifests
&lt;/h2&gt;

&lt;p&gt;winget does not describe a package in one file. It uses three, and they live together in one folder:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The version manifest&lt;/strong&gt; — the smallest. It names the package and points at the other two. Filename: &lt;code&gt;YourPublisher.YourTool.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The installer manifest&lt;/strong&gt; — the meat. Architecture, installer type, the download URL, the SHA256, and (for MSIs) the &lt;code&gt;ProductCode&lt;/code&gt;. Filename: &lt;code&gt;YourPublisher.YourTool.installer.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The default-locale manifest&lt;/strong&gt; — the human-readable metadata: publisher name, package name, description, license, homepage. Filename: &lt;code&gt;YourPublisher.YourTool.locale.en-US.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three sit in a path built from the identifier and version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;manifests/&amp;lt;first-letter&amp;gt;/&amp;lt;Publisher&amp;gt;/&amp;lt;Tool&amp;gt;/&amp;lt;Version&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a package identified as &lt;code&gt;SecurityRonin.sqlite4n6&lt;/code&gt; at version &lt;code&gt;0.1.0&lt;/code&gt;, that path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;manifests/s/SecurityRonin/sqlite4n6/0.1.0/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;first-letter&amp;gt;&lt;/code&gt; is the lowercased first letter of the publisher. Every new version gets its own version folder — &lt;code&gt;0.1.0/&lt;/code&gt;, &lt;code&gt;0.2.0/&lt;/code&gt;, … &lt;code&gt;0.10.1/&lt;/code&gt; — each with its own three files.&lt;/p&gt;

&lt;p&gt;The running example in this post is real: &lt;a href="https://github.com/SecurityRonin/sqlite-forensic" rel="noopener noreferrer"&gt;sqlite4n6&lt;/a&gt;, our read-only SQLite forensic CLI, published by &lt;code&gt;SecurityRonin&lt;/code&gt; as &lt;code&gt;SecurityRonin.sqlite4n6&lt;/code&gt;. (The package name and the repo name — &lt;code&gt;sqlite-forensic&lt;/code&gt; — 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 &lt;a href="https://github.com/microsoft/winget-pkgs/pull/390308" rel="noopener noreferrer"&gt;read the merged first-submission PR&lt;/a&gt; in &lt;code&gt;winget-pkgs&lt;/code&gt;. Substitute your own values everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PackageIdentifier
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;PackageIdentifier&lt;/code&gt; is the unique name a user types: &lt;code&gt;SecurityRonin.sqlite4n6&lt;/code&gt;. The convention is &lt;code&gt;Publisher.PackageName&lt;/code&gt; — 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four traps, named up front
&lt;/h2&gt;

&lt;p&gt;The map flagged four traps for this leg. Here they are, so you know what each later step is defending against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The action cannot create a package.&lt;/strong&gt; &lt;code&gt;winget-releaser&lt;/code&gt; (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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the winget release job &lt;code&gt;continue-on-error: true&lt;/code&gt; until that first PR merges.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UpgradeCode stable, ProductCode fresh.&lt;/strong&gt; Two GUIDs in your MSI. winget decides "this is an upgrade of the thing already installed" by matching the &lt;code&gt;UpgradeCode&lt;/code&gt;, so it must stay identical across every version. The &lt;code&gt;ProductCode&lt;/code&gt; identifies 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The action reads the &lt;code&gt;.msi&lt;/code&gt; off your release — attach it, or it dies with an empty &lt;code&gt;--urls&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;winget-releaser&lt;/code&gt; matches your &lt;code&gt;installers-regex&lt;/code&gt; (&lt;code&gt;\.msi$&lt;/code&gt;) against the assets on the GitHub Release for that tag, then feeds the matched download URLs to &lt;code&gt;wingetcreate&lt;/code&gt;. If your release publishes only the portable &lt;code&gt;.zip&lt;/code&gt; and not the built &lt;code&gt;.msi&lt;/code&gt;, the regex matches nothing and the action fails with &lt;code&gt;error: a value is required for '--urls' but none was supplied&lt;/code&gt; — even though the package exists in winget-pkgs and CI &lt;em&gt;built&lt;/em&gt; the MSI. Building it isn't publishing it: the release's upload glob has to include &lt;code&gt;*.msi&lt;/code&gt;, not just &lt;code&gt;*.zip&lt;/code&gt;. (Lived case: a release whose &lt;code&gt;files:&lt;/code&gt; listed &lt;code&gt;*.tar.gz&lt;/code&gt; and &lt;code&gt;*.zip&lt;/code&gt; but not &lt;code&gt;*.msi&lt;/code&gt; — green build, silently no winget update.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We will hit them in order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcbq4jwfxozksawh25puh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcbq4jwfxozksawh25puh.png" alt="The bootstrap path: the first version is a manual three-manifest PR to microsoft/winget-pkgs; once merged, the winget-releaser action auto-PRs every release after"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign the MSI before you submit it
&lt;/h2&gt;

&lt;p&gt;There is a step that belongs &lt;em&gt;before&lt;/em&gt; the first PR, because it changes how both winget's moderators and the end user's machine treat your installer: &lt;strong&gt;Authenticode-sign the MSI&lt;/strong&gt;. An unsigned &lt;code&gt;.exe&lt;/code&gt;/&lt;code&gt;.msi&lt;/code&gt; 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 &lt;strong&gt;verified publisher&lt;/strong&gt; instead, and the first submission moves more smoothly.&lt;/p&gt;

&lt;p&gt;Authenticode is that identity. The old way to get a cert was an &lt;strong&gt;EV code-signing certificate&lt;/strong&gt; on a &lt;strong&gt;USB HSM token&lt;/strong&gt; — expensive, physically mailed, and impossible to plug into CI. The new way is &lt;strong&gt;Azure Trusted Signing&lt;/strong&gt; (Microsoft rebranded it &lt;strong&gt;Azure Artifact Signing&lt;/strong&gt; mid-flight, which matters — see the RBAC gotcha below): about &lt;strong&gt;$9.99/month&lt;/strong&gt;, cloud-HSM-backed, short-lived certs, and CI-native via OIDC — no secret to store, no token to plug in.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-time setup
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Register the resource provider and deploy the account:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   az provider register &lt;span class="nt"&gt;--namespace&lt;/span&gt; Microsoft.CodeSigning
   az group create &lt;span class="nt"&gt;--name&lt;/span&gt; signing &lt;span class="nt"&gt;--location&lt;/span&gt; northeurope
   az deployment group create &lt;span class="nt"&gt;-g&lt;/span&gt; signing &lt;span class="nt"&gt;--template-uri&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="s2"&gt;"https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/quickstarts/microsoft.codesigning/codesigning-create-account/azuredeploy.json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--parameters&lt;/span&gt; &lt;span class="nv"&gt;accountName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;securityronin &lt;span class="nv"&gt;skuName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Basic &lt;span class="nv"&gt;location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;northeurope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Region gotcha:&lt;/strong&gt; a brand-new tenant is blocked from capacity-constrained regions. &lt;strong&gt;West Europe rejected the account&lt;/strong&gt; with &lt;code&gt;RequestDisallowedByAzure: "The selected region is currently not accepting new customers."&lt;/code&gt; Deploy to &lt;strong&gt;North Europe&lt;/strong&gt; (or a US region). Known-good: N.Europe, W.Europe, E.US, W.Central US, W.US3.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Public Trust identity validation.&lt;/strong&gt; In the Trusted Signing account → Identity validations → Organization → &lt;strong&gt;Public&lt;/strong&gt; — validated against Dun &amp;amp; Bradstreet. You must be a &lt;strong&gt;US / CA / EU / UK organization&lt;/strong&gt; with a &lt;strong&gt;D-U-N-S number&lt;/strong&gt;. Then create a &lt;strong&gt;Public Trust certificate profile&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Assign the RBAC roles — and mind the rebrand.&lt;/strong&gt; The role names are &lt;strong&gt;&lt;code&gt;Artifact Signing …&lt;/code&gt;, NOT "Trusted Signing …"&lt;/strong&gt; — the rebrand desynced the docs from the actual role-definition strings. You want &lt;strong&gt;&lt;code&gt;Artifact Signing Certificate Profile Signer&lt;/code&gt;&lt;/strong&gt; on the CI identity (and &lt;code&gt;Artifact Signing Identity Verifier&lt;/code&gt; for the human doing the validation). Discover the exact strings if in doubt:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   az role definition list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?contains(roleName,'Signing')].roleName"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create the CI identity as an Entra app + federated (OIDC) credential&lt;/strong&gt; — subject &lt;code&gt;repo:SecurityRonin/sqlite-forensic:ref:refs/tags/v[0-9]*&lt;/code&gt;, holding the Certificate Profile Signer role. There's &lt;strong&gt;no client secret to rotate&lt;/strong&gt;; the &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt; / &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt; you store as "secrets" are just non-sensitive IDs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The CI signing step
&lt;/h3&gt;

&lt;p&gt;Sign on the &lt;strong&gt;Windows runner&lt;/strong&gt;, &lt;strong&gt;after &lt;code&gt;cargo build&lt;/code&gt; produces the &lt;code&gt;.exe&lt;/code&gt; but before you feed it to &lt;code&gt;cargo-wix&lt;/code&gt;&lt;/strong&gt; — sign the bytes that ship, then package them. Then sign the built &lt;code&gt;.msi&lt;/code&gt; as its own step, so the installer clears SmartScreen too, not just the exes inside it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;        &lt;span class="c1"&gt;# OIDC → Azure (no stored client secret)&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="c1"&gt;# … after the .exe is built, before cargo-wix builds the MSI:&lt;/span&gt;

&lt;span class="c1"&gt;# OIDC login FIRST. The signing action's DefaultAzureCredential does NOT do the&lt;/span&gt;
&lt;span class="c1"&gt;# GitHub-OIDC exchange itself — azure/login trades the id-token for an az session&lt;/span&gt;
&lt;span class="c1"&gt;# the signer then rides on.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/login@&amp;lt;sha&amp;gt;&lt;/span&gt;                    &lt;span class="c1"&gt;# v3.0.0 — SHA-pin&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CLIENT_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;tenant-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_TENANT_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;subscription-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_SUBSCRIPTION_ID }}&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/trusted-signing-action@&amp;lt;sha&amp;gt;&lt;/span&gt;   &lt;span class="c1"&gt;# SHA-pin&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                                      &lt;span class="c1"&gt;# NO azure-* auth inputs — auth = the login session&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://neu.codesigning.azure.net&lt;/span&gt;      &lt;span class="c1"&gt;# North Europe — region-specific!&lt;/span&gt;
    &lt;span class="na"&gt;trusted-signing-account-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;securityronin&lt;/span&gt;
    &lt;span class="na"&gt;certificate-profile-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;securityronin-public&lt;/span&gt;
    &lt;span class="na"&gt;files-folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;target/${{ matrix.target }}/release&lt;/span&gt;
    &lt;span class="na"&gt;files-folder-filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;exe&lt;/span&gt;
    &lt;span class="na"&gt;file-digest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SHA256&lt;/span&gt;
    &lt;span class="na"&gt;timestamp-rfc3161&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://timestamp.acs.microsoft.com&lt;/span&gt;
    &lt;span class="na"&gt;timestamp-digest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SHA256&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the &lt;em&gt;same&lt;/em&gt; signing action a second time against the built &lt;code&gt;.msi&lt;/code&gt; (filter &lt;code&gt;msi&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The three gotchas that will cost you an afternoon:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;azure/login&lt;/code&gt; before the signer is mandatory — it's the #1 CI failure.&lt;/strong&gt; Without it, the action falls through to &lt;code&gt;AzureCliCredential&lt;/code&gt; and dies &lt;code&gt;Please run 'az login'&lt;/code&gt; — a maddeningly misleading error, because it's a missing &lt;em&gt;step&lt;/em&gt;, not a local mistake. Run &lt;code&gt;azure/login&lt;/code&gt; first, drop the &lt;code&gt;azure-*&lt;/code&gt; inputs from the signing action, and remember to pass &lt;code&gt;AZURE_SUBSCRIPTION_ID&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint is region-specific and must match the account's region&lt;/strong&gt; — &lt;code&gt;neu&lt;/code&gt; / &lt;code&gt;weu&lt;/code&gt; / &lt;code&gt;eus&lt;/code&gt; / … &lt;code&gt;.codesigning.azure.net&lt;/code&gt;. Wrong endpoint = a confusing auth failure that looks like a permissions problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign before you package.&lt;/strong&gt; Sign the &lt;code&gt;.exe&lt;/code&gt;, &lt;em&gt;then&lt;/em&gt; build the MSI, and sign the MSI as its own step. Signing the wrapper doesn't sign what's inside it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the MSI signed, the SHA256 you read out for the manifest (next section) is the hash of the &lt;em&gt;signed&lt;/em&gt; installer — so sign first, then hash, then submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1 — The manual first submission
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Get the SHA256 and the ProductCode from your MSI
&lt;/h3&gt;

&lt;p&gt;Your installer manifest needs two values pulled out of the actual MSI file: its SHA256 hash, and its &lt;code&gt;ProductCode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The SHA256 is straightforward. On Windows PowerShell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-FileHash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Algorithm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SHA256&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this MSI that prints &lt;code&gt;3BFD608862C6305BF9401930ED9608CD50B34CBFC0DAD18484116E0AE6DE93A7&lt;/code&gt;. Copy the hash. winget wants it uppercase; PowerShell already gives it to you that way.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ProductCode&lt;/code&gt; is a GUID baked into the MSI. An MSI stores it in a &lt;code&gt;Property&lt;/code&gt; table inside the file, under the property name &lt;code&gt;ProductCode&lt;/code&gt;. Two ways to read it:&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;msitools&lt;/code&gt; (the &lt;code&gt;msiinfo&lt;/code&gt; command, available on Linux/macOS or via MSYS2 on Windows):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;msiinfo &lt;span class="nb"&gt;export &lt;/span&gt;sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi Property | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^Product(Code|Version)|^UpgradeCode'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That dumps the Property table; for this MSI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;ProductCode&lt;/span&gt; &lt;span class="err"&gt;{0EB6897B-0234-46DC-8810-EACA6E0BFDB9}&lt;/span&gt;
&lt;span class="err"&gt;ProductVersion&lt;/span&gt;  &lt;span class="err"&gt;0.1.0&lt;/span&gt;
&lt;span class="err"&gt;UpgradeCode&lt;/span&gt; &lt;span class="err"&gt;{070DCA3F-F901-4736-9C5D-12F7AA00F064}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or natively in PowerShell, using the Windows Installer COM object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$installer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;New-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ComObject&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;WindowsInstaller.Installer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$installer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;".\sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT Value FROM Property WHERE Property='ProductCode'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way you get a GUID in braces — &lt;code&gt;{0EB6897B-0234-46DC-8810-EACA6E0BFDB9}&lt;/code&gt; for this build. That is your &lt;code&gt;ProductCode&lt;/code&gt;. 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 &lt;code&gt;ProductCode&lt;/code&gt; has become &lt;code&gt;{2F509EB7-EBBA-423D-958F-D399AAE971FA}&lt;/code&gt; while the &lt;code&gt;UpgradeCode&lt;/code&gt; is still &lt;code&gt;{070DCA3F-F901-4736-9C5D-12F7AA00F064}&lt;/code&gt; — fresh every build, stable forever, respectively.)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where do these GUIDs come from? They are set in your &lt;code&gt;wix/main.wxs&lt;/code&gt;, the WiX source &lt;em&gt;The Windows MSI&lt;/em&gt; post covers. The &lt;code&gt;UpgradeCode&lt;/code&gt; is an attribute you hardcode once and never touch. The &lt;code&gt;ProductCode&lt;/code&gt; is the &lt;code&gt;Product/@Id&lt;/code&gt; — and if you set it to &lt;code&gt;*&lt;/code&gt; (the WiX convention for "autogenerate"), WiX mints a new one on every build. That is exactly why it is fresh each release.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2: Write the version manifest
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;SecurityRonin.sqlite4n6.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json&lt;/span&gt;

&lt;span class="na"&gt;PackageIdentifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityRonin.sqlite4n6&lt;/span&gt;
&lt;span class="na"&gt;PackageVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;DefaultLocale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;en-US&lt;/span&gt;
&lt;span class="na"&gt;ManifestType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;version&lt;/span&gt;
&lt;span class="na"&gt;ManifestVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.12.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;# yaml-language-server&lt;/code&gt; comment is optional but worth keeping — editors with a YAML language server validate the file against the schema as you type.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PackageIdentifier&lt;/code&gt; — the unique name, exactly as it appears in the folder path and the other two files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PackageVersion&lt;/code&gt; — &lt;code&gt;0.1.0&lt;/code&gt;. This must match the version folder name and the version in the other manifests.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DefaultLocale&lt;/code&gt; — &lt;code&gt;en-US&lt;/code&gt;, telling winget which locale manifest is the fallback. It must match the locale of your locale file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManifestType&lt;/code&gt; — &lt;code&gt;version&lt;/code&gt;. This is what marks this file as the version manifest.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManifestVersion&lt;/code&gt; — the schema version of the manifest format itself (not your app's version). Use a current one — &lt;code&gt;1.12.0&lt;/code&gt; here; check the &lt;a href="https://github.com/microsoft/winget-pkgs/tree/master/doc/manifest/schema" rel="noopener noreferrer"&gt;winget-pkgs manifest schemas&lt;/a&gt; for the latest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: Write the installer manifest
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;SecurityRonin.sqlite4n6.installer.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json&lt;/span&gt;

&lt;span class="na"&gt;PackageIdentifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityRonin.sqlite4n6&lt;/span&gt;
&lt;span class="na"&gt;PackageVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;InstallerLocale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;en-US&lt;/span&gt;
&lt;span class="na"&gt;InstallerType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wix&lt;/span&gt;
&lt;span class="na"&gt;Scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;machine&lt;/span&gt;
&lt;span class="na"&gt;InstallModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;interactive&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;silent&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;silentWithProgress&lt;/span&gt;
&lt;span class="na"&gt;Dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PackageDependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;PackageIdentifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Microsoft.VCRedist.2015+.x64&lt;/span&gt;
&lt;span class="na"&gt;ProductCode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{0EB6897B-0234-46DC-8810-EACA6E0BFDB9}'&lt;/span&gt;
&lt;span class="na"&gt;ReleaseDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-06-18&lt;/span&gt;
&lt;span class="na"&gt;AppsAndFeaturesEntries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Publisher&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Security Ronin&lt;/span&gt;
  &lt;span class="na"&gt;ProductCode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{0EB6897B-0234-46DC-8810-EACA6E0BFDB9}'&lt;/span&gt;
  &lt;span class="na"&gt;UpgradeCode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{070DCA3F-F901-4736-9C5D-12F7AA00F064}'&lt;/span&gt;
&lt;span class="na"&gt;InstallationMetadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DefaultInstallLocation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%ProgramFiles%\sqlite4n6\bin'&lt;/span&gt;
&lt;span class="na"&gt;Installers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Architecture&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;x64&lt;/span&gt;
  &lt;span class="na"&gt;InstallerUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/SecurityRonin/sqlite-forensic/releases/download/v0.1.0/sqlite4n6-0.1.0-x86_64-pc-windows-msvc.msi&lt;/span&gt;
  &lt;span class="na"&gt;InstallerSha256&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3BFD608862C6305BF9401930ED9608CD50B34CBFC0DAD18484116E0AE6DE93A7&lt;/span&gt;
&lt;span class="na"&gt;ManifestType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;installer&lt;/span&gt;
&lt;span class="na"&gt;ManifestVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.12.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Field by field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PackageIdentifier&lt;/code&gt;, &lt;code&gt;PackageVersion&lt;/code&gt; — same values as the version manifest. They tie the three files together.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InstallerType&lt;/code&gt; — &lt;code&gt;wix&lt;/code&gt; for an MSI built by WiX (which &lt;code&gt;cargo-wix&lt;/code&gt; is). The plain value &lt;code&gt;msi&lt;/code&gt; also works; &lt;code&gt;wix&lt;/code&gt; is the more specific type for a WiX-authored MSI and is what winget recommends for these. Both are valid &lt;code&gt;InstallerType&lt;/code&gt; values.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Scope: machine&lt;/code&gt; — this MSI installs for all users under &lt;code&gt;%ProgramFiles%&lt;/code&gt;, not per-user. Declare it so winget shows and handles the install correctly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InstallModes&lt;/code&gt; — which of winget's install experiences the MSI supports. An MSI built by &lt;code&gt;cargo-wix&lt;/code&gt; supports all three; listing them lets the user pick &lt;code&gt;--interactive&lt;/code&gt; or (the default) silent.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Dependencies&lt;/code&gt; — packages winget should install first. A Rust binary built with the MSVC toolchain links the VC++ runtime, so declare &lt;code&gt;Microsoft.VCRedist.2015+.x64&lt;/code&gt; rather than hoping the target machine has it. This is the difference between "works on most machines" and "works on a fresh VM".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ProductCode&lt;/code&gt; — the GUID from Step 1, in braces, quoted because YAML otherwise tries to read &lt;code&gt;{…}&lt;/code&gt; as a map. This is the value that changes every release.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ReleaseDate&lt;/code&gt; — the release's date, straight off the GitHub Release.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AppsAndFeaturesEntries&lt;/code&gt; — how the installed app appears in Windows' Apps &amp;amp; Features list: display publisher, and the &lt;code&gt;ProductCode&lt;/code&gt;/&lt;code&gt;UpgradeCode&lt;/code&gt; pair that lets winget match an already-installed copy to this package. This is the one place the &lt;code&gt;UpgradeCode&lt;/code&gt; appears in a manifest — winget uses it to recognize "this installed thing is an older version of that package" even when every version's &lt;code&gt;ProductCode&lt;/code&gt; differs. Your job is still to keep it stable in the &lt;code&gt;wxs&lt;/code&gt; (see the box in Step 1).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InstallationMetadata.DefaultInstallLocation&lt;/code&gt; — where the MSI puts the files; lets winget find the install for repair/portable-alias purposes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Installers&lt;/code&gt; — a list, one entry per architecture you ship. Most Rust CLIs ship one x64 MSI, so one entry.

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Architecture&lt;/code&gt; — &lt;code&gt;x64&lt;/code&gt; for &lt;code&gt;x86_64&lt;/code&gt;. (Use &lt;code&gt;arm64&lt;/code&gt; if you also ship an ARM MSI; &lt;code&gt;x86&lt;/code&gt; for 32-bit.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InstallerUrl&lt;/code&gt; — the direct download URL of the MSI asset on your GitHub Release. This is the URL the &lt;em&gt;one-tag workflow&lt;/em&gt; produces — &lt;code&gt;…/releases/download/&amp;lt;tag&amp;gt;/&amp;lt;msi-filename&amp;gt;&lt;/code&gt;. It must be a stable, public, direct link, not a redirect page.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InstallerSha256&lt;/code&gt; — 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManifestType&lt;/code&gt; — &lt;code&gt;installer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManifestVersion&lt;/code&gt; — same schema version as the other two files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Strictly, only the identifier/version/type/URL/hash core is required — but the middle block (&lt;code&gt;Scope&lt;/code&gt;, &lt;code&gt;InstallModes&lt;/code&gt;, &lt;code&gt;Dependencies&lt;/code&gt;, &lt;code&gt;AppsAndFeaturesEntries&lt;/code&gt;, &lt;code&gt;InstallationMetadata&lt;/code&gt;) 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Write the default-locale manifest
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;SecurityRonin.sqlite4n6.locale.en-US.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json&lt;/span&gt;

&lt;span class="na"&gt;PackageIdentifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityRonin.sqlite4n6&lt;/span&gt;
&lt;span class="na"&gt;PackageVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;PackageLocale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;en-US&lt;/span&gt;
&lt;span class="na"&gt;Publisher&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityRonin&lt;/span&gt;
&lt;span class="na"&gt;PublisherUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/SecurityRonin&lt;/span&gt;
&lt;span class="na"&gt;PublisherSupportUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/SecurityRonin/sqlite-forensic/issues&lt;/span&gt;
&lt;span class="na"&gt;PackageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sqlite4n6&lt;/span&gt;
&lt;span class="na"&gt;PackageUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/SecurityRonin/sqlite-forensic&lt;/span&gt;
&lt;span class="na"&gt;License&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Apache-2.0&lt;/span&gt;
&lt;span class="na"&gt;LicenseUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/SecurityRonin/sqlite-forensic/blob/HEAD/LICENSE&lt;/span&gt;
&lt;span class="na"&gt;Copyright&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Copyright (c) SecurityRonin&lt;/span&gt;
&lt;span class="na"&gt;ShortDescription&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read-only SQLite forensic CLI — carve deleted records, grade anomalies&lt;/span&gt;
&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
  &lt;span class="s"&gt;sqlite4n6 is a read-only SQLite forensic CLI. It carves deleted records out of&lt;/span&gt;
  &lt;span class="s"&gt;a database's free (unallocated) space — freelist pages, in-page free blocks,&lt;/span&gt;
  &lt;span class="s"&gt;dropped-table pages, and an uncheckpointed WAL overlay — recovering rows a live&lt;/span&gt;
  &lt;span class="s"&gt;query cannot, and grades forensically-notable anomalies into severity-ranked&lt;/span&gt;
  &lt;span class="s"&gt;findings. It opens the evidence file read-only and never writes the file or its&lt;/span&gt;
  &lt;span class="s"&gt;sidecars.&lt;/span&gt;
&lt;span class="na"&gt;Moniker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sqlite4n6&lt;/span&gt;
&lt;span class="na"&gt;Tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;carving&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cli&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;data-recovery&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;deleted-records&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dfir&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;forensics&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sqlite&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wal&lt;/span&gt;
&lt;span class="na"&gt;ReleaseNotes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Full&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Changelog:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;https://github.com/SecurityRonin/sqlite-forensic/commits/v0.1.0'&lt;/span&gt;
&lt;span class="na"&gt;ReleaseNotesUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/SecurityRonin/sqlite-forensic/releases/tag/v0.1.0&lt;/span&gt;
&lt;span class="na"&gt;ManifestType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;defaultLocale&lt;/span&gt;
&lt;span class="na"&gt;ManifestVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.12.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PackageIdentifier&lt;/code&gt;, &lt;code&gt;PackageVersion&lt;/code&gt; — same as the others.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PackageLocale&lt;/code&gt; — &lt;code&gt;en-US&lt;/code&gt;, and it must equal the &lt;code&gt;DefaultLocale&lt;/code&gt; from the version manifest.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Publisher&lt;/code&gt; — the human-readable publisher name (the legal/brand name), distinct from the &lt;code&gt;PackageIdentifier&lt;/code&gt; prefix.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PublisherUrl&lt;/code&gt; — the publisher's site or GitHub org. Optional but expected.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PackageName&lt;/code&gt; — the display name users see in &lt;code&gt;winget search&lt;/code&gt; and &lt;code&gt;winget show&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PackageUrl&lt;/code&gt; — the project homepage.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;License&lt;/code&gt; — the SPDX license identifier (&lt;code&gt;Apache-2.0&lt;/code&gt;, &lt;code&gt;MIT&lt;/code&gt;, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LicenseUrl&lt;/code&gt; — link to the license text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShortDescription&lt;/code&gt; — one sentence. This shows up in search results, so make it say what the tool does.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Description&lt;/code&gt; — the long-form version, shown by &lt;code&gt;winget show&lt;/code&gt;. Say what the tool actually does, concretely.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PublisherSupportUrl&lt;/code&gt; — where users report problems; the repo's issues page is the natural value.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Copyright&lt;/code&gt; — a one-line copyright string.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Moniker&lt;/code&gt; — an optional short alias users can install by (&lt;code&gt;winget install sqlite4n6&lt;/code&gt;). Not unique like the identifier; treat it as a convenience.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Tags&lt;/code&gt; — search keywords.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ReleaseNotes&lt;/code&gt; / &lt;code&gt;ReleaseNotesUrl&lt;/code&gt; — per-version notes; the auto-update tooling fills these from the GitHub Release on every bump.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManifestType&lt;/code&gt; — &lt;code&gt;defaultLocale&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ManifestVersion&lt;/code&gt; — same schema version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;Publisher&lt;/code&gt;, &lt;code&gt;PackageName&lt;/code&gt;, &lt;code&gt;License&lt;/code&gt;, &lt;code&gt;ShortDescription&lt;/code&gt; are the required ones; the rest are strongly recommended and will make your &lt;code&gt;winget show&lt;/code&gt; output look complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Validate locally before you submit
&lt;/h3&gt;

&lt;p&gt;You have three files. Before you fork anything, check that winget itself accepts them. On a Windows machine with winget installed, point &lt;code&gt;winget validate&lt;/code&gt; at the folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;winget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;validate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--manifest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\manifests\s\SecurityRonin\sqlite4n6\0.1.0\&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It parses all three files and reports schema errors — a wrong &lt;code&gt;ManifestType&lt;/code&gt;, a mismatched version, a missing required field. Fix until it says the manifests are valid.&lt;/p&gt;

&lt;p&gt;Then test that the package actually &lt;em&gt;installs&lt;/em&gt; from your manifests, using a local install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;winget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;install&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--manifest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\manifests\s\SecurityRonin\sqlite4n6\0.1.0\&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the real test. winget reads your installer manifest, downloads the MSI from &lt;code&gt;InstallerUrl&lt;/code&gt;, checks the SHA256 against &lt;code&gt;InstallerSha256&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpoint.&lt;/strong&gt; Before moving on: &lt;code&gt;winget validate&lt;/code&gt; passes, and &lt;code&gt;winget install --manifest&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Fork, place the files, open the PR
&lt;/h3&gt;

&lt;p&gt;The submission is an ordinary GitHub pull request to &lt;code&gt;microsoft/winget-pkgs&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fork &lt;code&gt;microsoft/winget-pkgs&lt;/code&gt; to your account or org. (The auto-update action later expects a fork to exist; forking now does double duty.)&lt;/li&gt;
&lt;li&gt;Clone your fork, create a branch, and add your three files at the exact path:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="s"&gt;manifests/s/SecurityRonin/sqlite4n6/0.1.0/SecurityRonin.sqlite4n6.yaml&lt;/span&gt;
   &lt;span class="s"&gt;manifests/s/SecurityRonin/sqlite4n6/0.1.0/SecurityRonin.sqlite4n6.installer.yaml&lt;/span&gt;
   &lt;span class="s"&gt;manifests/s/SecurityRonin/sqlite4n6/0.1.0/SecurityRonin.sqlite4n6.locale.en-US.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Commit, push to your fork, and open a PR against &lt;code&gt;microsoft/winget-pkgs&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpoint.&lt;/strong&gt; The PR is merged — for sqlite4n6 that was &lt;a href="https://github.com/microsoft/winget-pkgs/pull/390308" rel="noopener noreferrer"&gt;winget-pkgs PR #390308&lt;/a&gt;. &lt;code&gt;SecurityRonin.sqlite4n6&lt;/code&gt; is now a real package. From here on, you never hand-author manifests again — the action does it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2 — Auto-updates for every release after
&lt;/h2&gt;

&lt;p&gt;Now that the package exists, the &lt;code&gt;winget-releaser&lt;/code&gt; action can bump it. It runs in your &lt;em&gt;project&lt;/em&gt; 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 &lt;code&gt;ProductCode&lt;/code&gt; itself.&lt;/p&gt;

&lt;p&gt;This job belongs in the same release pipeline as everything else — it is one more fan-out target from &lt;em&gt;the one-tag workflow&lt;/em&gt;. Add it as a job that runs after the GitHub Release is published:&lt;/p&gt;

&lt;p&gt;This is the live job from sqlite4n6's &lt;code&gt;release.yml&lt;/code&gt;, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;winget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;windows-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# First-time winget submission requires a manual PR; continue-on-error until registered&lt;/span&gt;
    &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e&lt;/span&gt; &lt;span class="c1"&gt;# v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;identifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityRonin.sqlite4n6&lt;/span&gt;
          &lt;span class="na"&gt;installers-regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\.msi$'&lt;/span&gt;
          &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.WINGET_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;fork-user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;securityronin-bot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each piece does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;needs: release&lt;/code&gt; — 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 &lt;em&gt;published&lt;/em&gt;, non-draft release, because the asset has to be publicly downloadable.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;continue-on-error: true&lt;/code&gt; — &lt;strong&gt;the second trap.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;identifier&lt;/code&gt; — the &lt;code&gt;PackageIdentifier&lt;/code&gt;, exactly as registered. This is how the action finds the existing package to bump.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;installers-regex&lt;/code&gt; — which Release assets are the installers. The default matches several installer extensions; narrowing it to &lt;code&gt;\.msi$&lt;/code&gt; makes sure it grabs your MSI and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;token&lt;/code&gt; — 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 &lt;strong&gt;classic&lt;/strong&gt; PAT with &lt;code&gt;public_repo&lt;/code&gt; scope — the action does not support fine-grained tokens. (Keep this token as an &lt;em&gt;organization&lt;/em&gt; secret, like the rest of your publish tokens, per the secrets lesson in the map.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fork-user&lt;/code&gt; — the account holding the winget-pkgs fork the action pushes to. The fleet uses a dedicated bot account (&lt;code&gt;securityronin-bot&lt;/code&gt;) so the PR spam lands on a machine identity, not a human's fork; the &lt;code&gt;token&lt;/code&gt; must belong to this account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;ProductCode&lt;/code&gt; read from the new MSI. You do nothing per release except push your &lt;code&gt;v*&lt;/code&gt; tag — the same tag that drives every other channel.&lt;/p&gt;

&lt;p&gt;The action is pinned to a full commit SHA (with the &lt;code&gt;# v2&lt;/code&gt; comment recording the human-readable version) — the supply-chain discipline from the map applied to one more third-party action. Check its &lt;a href="https://github.com/vedantmgoyal9/winget-releaser/releases" rel="noopener noreferrer"&gt;releases&lt;/a&gt; when bumping the pin, and let Renovate or Dependabot keep the SHA current.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it actually worked
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;winget source update&lt;/code&gt;. Verify it for real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;winget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;winget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SecurityRonin.sqlite4n6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;winget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;install&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SecurityRonin.sqlite4n6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(These are live commands — the package is real, so you can run them on any Windows machine right now.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;winget search&lt;/code&gt; should list your package; &lt;code&gt;winget install&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;For the &lt;em&gt;next&lt;/em&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;winget&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;upgrade&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SecurityRonin.sqlite4n6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;winget upgrade&lt;/code&gt; does not see your new version as an upgrade of the installed one, the usual cause is an &lt;code&gt;UpgradeCode&lt;/code&gt; that changed between builds — winget no longer recognizes the new MSI as the same product. That brings us to the recap.&lt;/p&gt;

&lt;h2&gt;
  
  
  No MSI? The portable-zip route — and the type-flip trap
&lt;/h2&gt;

&lt;p&gt;Everything above assumes an MSI. winget also installs plain zips: our &lt;a href="https://github.com/SecurityRonin/timeglyph" rel="noopener noreferrer"&gt;timeglyph&lt;/a&gt; shipped its first winget versions as &lt;code&gt;SecurityRonin.timeglyph&lt;/code&gt; with nothing but the zip the release matrix already produced. The installer manifest swaps the MSI fields for three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;InstallerType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zip&lt;/span&gt;
&lt;span class="na"&gt;NestedInstallerType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portable&lt;/span&gt;
&lt;span class="na"&gt;NestedInstallerFiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;RelativeFilePath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;timeglyph.exe&lt;/span&gt;
  &lt;span class="na"&gt;PortableCommandAlias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;timeglyph&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;winget downloads the zip, extracts &lt;code&gt;timeglyph.exe&lt;/code&gt;, and shims it onto the user's PATH under the alias. No &lt;code&gt;ProductCode&lt;/code&gt;, no &lt;code&gt;UpgradeCode&lt;/code&gt;, 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-&lt;code&gt;winget-releaser&lt;/code&gt; bootstrap applies unchanged.&lt;/p&gt;

&lt;p&gt;The trade-offs are real, though: a portable install puts nothing in Apps &amp;amp; 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: &lt;strong&gt;&lt;code&gt;winget-releaser&lt;/code&gt; bumps versions, it does not change a package's installer type.&lt;/strong&gt; Moving a package from &lt;code&gt;zip&lt;/code&gt; to &lt;code&gt;msi&lt;/code&gt; is another one-time manual manifest PR — the same bootstrap ritual as the first submission, once per structural change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bit me, and the fix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What bit me&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;th&gt;The fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The auto-update action failed on the very first release&lt;/td&gt;
&lt;td&gt;The package did not exist yet; the action only bumps existing packages&lt;/td&gt;
&lt;td&gt;Hand-author three manifests and open a manual PR to &lt;code&gt;microsoft/winget-pkgs&lt;/code&gt; for the first version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every release run went red because of winget&lt;/td&gt;
&lt;td&gt;The action fails (correctly) until the first PR merges&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;continue-on-error: true&lt;/code&gt; on the winget job; remove it once the bootstrap PR is merged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;winget upgrade&lt;/code&gt; stopped recognizing new versions&lt;/td&gt;
&lt;td&gt;The MSI's &lt;code&gt;UpgradeCode&lt;/code&gt; changed between builds&lt;/td&gt;
&lt;td&gt;Hardcode the &lt;code&gt;UpgradeCode&lt;/code&gt; once in &lt;code&gt;wix/main.wxs&lt;/code&gt; and never change it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation rejected a stale &lt;code&gt;ProductCode&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;ProductCode&lt;/code&gt; changes every build; the manifest carried the old one&lt;/td&gt;
&lt;td&gt;Re-read the &lt;code&gt;ProductCode&lt;/code&gt; from each new MSI (&lt;code&gt;msiinfo export … Property&lt;/code&gt; or the PowerShell COM call); the action does this for you on auto-bumps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The action could not open a PR (auth error)&lt;/td&gt;
&lt;td&gt;A fine-grained PAT, or one without &lt;code&gt;public_repo&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use a &lt;strong&gt;classic&lt;/strong&gt; PAT with &lt;code&gt;public_repo&lt;/code&gt; scope, stored as a secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;winget install&lt;/code&gt; failed on hash mismatch&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;InstallerSha256&lt;/code&gt; did not match the actual MSI&lt;/td&gt;
&lt;td&gt;Recompute the SHA256 from the exact file you uploaded; test with &lt;code&gt;winget install --manifest&lt;/code&gt; before submitting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;InstallerUrl&lt;/code&gt; 404'd during install&lt;/td&gt;
&lt;td&gt;URL pointed at a release page or a moved asset&lt;/td&gt;
&lt;td&gt;Use the direct &lt;code&gt;…/releases/download/&amp;lt;tag&amp;gt;/&amp;lt;file&amp;gt;.msi&lt;/code&gt; link from the GitHub Release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shape of it: one manual PR to bootstrap the package, one &lt;code&gt;continue-on-error&lt;/code&gt; flag to survive the gap, one stable &lt;code&gt;UpgradeCode&lt;/code&gt; and one fresh &lt;code&gt;ProductCode&lt;/code&gt; per build. Past that, your &lt;code&gt;v*&lt;/code&gt; tag carries winget along with everything else — the action regenerates the manifests, opens the PR, and a stranger on Windows types &lt;code&gt;winget install SecurityRonin.sqlite4n6&lt;/code&gt; and gets your tool.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>winget</category>
      <category>windows</category>
      <category>packaging</category>
    </item>
    <item>
      <title>Homebrew Without the Footguns</title>
      <dc:creator>Albert Hui</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:40:54 +0000</pubDate>
      <link>https://dev.to/4n6h4x0r/homebrew-without-the-footguns-3ego</link>
      <guid>https://dev.to/4n6h4x0r/homebrew-without-the-footguns-3ego</guid>
      <description>&lt;p&gt;Part of the &lt;em&gt;Shipping Large Rust Apps&lt;/em&gt; series — start with &lt;a href="https://dev.to/4n6h4x0r/the-half-of-rust-nobody-teaches-shipping-56em"&gt;the map&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You have a built binary attached to a GitHub Release. A Mac user wants to type &lt;code&gt;brew install timeglyph&lt;/code&gt; and have it work — and if your tool has a desktop app, &lt;code&gt;brew install --cask yourapp&lt;/code&gt; should work just as well. This post takes you from there, assuming you have never made a Homebrew formula, never made a tap, and have never heard the words "bottle" or "repository_dispatch", to a tap that installs your tool and updates itself on every new tag. The CLI formula comes first because it needs no signing at all; the GUI cask and its Gatekeeper paperwork build on the same tap at the end.&lt;/p&gt;

&lt;p&gt;The map flagged three traps that cost an afternoon each: use one shared tap (not one per tool), give every project its own dispatch event type, and make sure the dispatching bot has write access. We'll build the whole thing around those three, from zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vocabulary, in plain terms
&lt;/h2&gt;

&lt;p&gt;Three words, and then nothing in this post is mysterious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tap is a GitHub repository.&lt;/strong&gt; That's the whole secret. A "tap" is just a repo whose name starts with &lt;code&gt;homebrew-&lt;/code&gt;. When you run &lt;code&gt;brew install yourorg/tap/timeglyph&lt;/code&gt;, Homebrew expands that into "go to &lt;code&gt;github.com/yourorg/homebrew-tap&lt;/code&gt;, find the formula named &lt;code&gt;timeglyph&lt;/code&gt;, and install it." The &lt;code&gt;yourorg/tap&lt;/code&gt; in the command is shorthand: the &lt;code&gt;yourorg&lt;/code&gt; is the GitHub org or user, and &lt;code&gt;tap&lt;/code&gt; is the part after &lt;code&gt;homebrew-&lt;/code&gt; in the repo name &lt;code&gt;homebrew-tap&lt;/code&gt;. So &lt;code&gt;SecurityRonin/homebrew-tap&lt;/code&gt; is installed as &lt;code&gt;SecurityRonin/tap&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A formula is a Ruby file.&lt;/strong&gt; Inside the tap repo, formulas live in a &lt;code&gt;Formula/&lt;/code&gt; directory. Each one is a &lt;code&gt;.rb&lt;/code&gt; file — &lt;code&gt;Formula/timeglyph.rb&lt;/code&gt; — that tells Homebrew where to download your binary, how to verify it, and where to put it. You do not need to know Ruby. A formula is a fill-in-the-blanks template, and we'll fill in every blank below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bottle is a pre-compiled formula.&lt;/strong&gt; You'll see the word in Homebrew docs. For a formula that downloads an already-built binary (which is what we're doing — we built it in the release workflow, we are not asking Homebrew to compile Rust on the user's machine), bottles don't apply. Ignore the word. We download a tarball that already contains the executable.&lt;/p&gt;

&lt;p&gt;So the plan: one repo named &lt;code&gt;homebrew-tap&lt;/code&gt;, with one Ruby file per tool inside &lt;code&gt;Formula/&lt;/code&gt;, each pointing at a GitHub Release asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  One tap for the whole fleet
&lt;/h2&gt;

&lt;p&gt;The first instinct is one tap per tool: &lt;code&gt;homebrew-timeglyph&lt;/code&gt;, &lt;code&gt;homebrew-other-tool&lt;/code&gt;, and so on. Don't. Make &lt;strong&gt;one&lt;/strong&gt; tap repo — &lt;code&gt;homebrew-tap&lt;/code&gt; — and put every tool's formula inside it.&lt;/p&gt;

&lt;p&gt;Why one tap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The install command is cleaner and consistent.&lt;/strong&gt; Every tool in the fleet is &lt;code&gt;brew install yourorg/tap/&amp;lt;tool&amp;gt;&lt;/code&gt;. Users learn the prefix once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;brew tap yourorg/tap&lt;/code&gt; once gives them everything.&lt;/strong&gt; After a single &lt;code&gt;brew tap&lt;/code&gt;, every tool you ship is installable by bare name. A tap-per-tool forces a separate tap for each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One place to wire auto-updates.&lt;/strong&gt; The handler workflows that bump formulas all live in one repo. You set up the secret and the write access once, not N times.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is that all formulas share one repo's commit history, which is a non-issue. Make the one tap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — create the tap repo
&lt;/h2&gt;

&lt;p&gt;On GitHub, create a new public repository named exactly &lt;code&gt;homebrew-tap&lt;/code&gt; under your org (here, &lt;code&gt;SecurityRonin&lt;/code&gt;). Add a &lt;code&gt;Formula/&lt;/code&gt; directory. That's it — an empty &lt;code&gt;Formula/&lt;/code&gt; directory and a README is a valid tap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpoint:&lt;/strong&gt; you should be able to run &lt;code&gt;brew tap SecurityRonin/tap&lt;/code&gt; and have it succeed (it'll just find no formulas yet). If &lt;code&gt;brew tap&lt;/code&gt; errors, the repo name is wrong — it must start with &lt;code&gt;homebrew-&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — write the first formula by hand
&lt;/h2&gt;

&lt;p&gt;The auto-update workflow (Step 4) regenerates this file on every release, but you need a correct first version to seed it, and writing one by hand is how you'll understand what the automation is doing.&lt;/p&gt;

&lt;p&gt;Here is a complete formula for a Rust binary distributed as a release tarball. The running example is real: &lt;a href="https://github.com/SecurityRonin/timeglyph" rel="noopener noreferrer"&gt;timeglyph&lt;/a&gt;, our forensic timestamp decoder, whose release workflow (the one from &lt;em&gt;one tag, every artifact&lt;/em&gt;) attached an asset named &lt;code&gt;timeglyph-0.7.1-aarch64-apple-darwin.tar.gz&lt;/code&gt; to the &lt;code&gt;v0.7.1&lt;/code&gt; release. The tarball carries the &lt;code&gt;timeglyph&lt;/code&gt; executable (plus a companion GUI binary we'll meet at the end). Start with the smallest thing that works — one architecture, one binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Timeglyph&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Formula&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Forensic timestamp decipherment — scored, cited, ambiguity-first"&lt;/span&gt;
  &lt;span class="n"&gt;homepage&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/SecurityRonin/timeglyph"&lt;/span&gt;
  &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s2"&gt;"0.7.1"&lt;/span&gt;
  &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-apple-darwin.tar.gz"&lt;/span&gt;
  &lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="s2"&gt;"0000000000000000000000000000000000000000000000000000000000000000"&lt;/span&gt;
  &lt;span class="n"&gt;license&lt;/span&gt; &lt;span class="s2"&gt;"Apache-2.0"&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;install&lt;/span&gt;
    &lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"timeglyph"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;assert_match&lt;/span&gt; &lt;span class="s2"&gt;"timeglyph"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shell_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/timeglyph --version"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line, top to bottom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;class Timeglyph &amp;lt; Formula&lt;/code&gt; — the class name is the formula name in CamelCase. File &lt;code&gt;timeglyph.rb&lt;/code&gt; becomes class &lt;code&gt;Timeglyph&lt;/code&gt;; a hyphenated name like &lt;code&gt;report-cli&lt;/code&gt; would become &lt;code&gt;ReportCli&lt;/code&gt; — hyphens drop and each word capitalizes. Homebrew enforces this mapping; get it wrong and &lt;code&gt;brew audit&lt;/code&gt; complains.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;desc&lt;/code&gt; — a one-line description. Keep it short; &lt;code&gt;brew audit&lt;/code&gt; rejects descriptions that start with "A"/"An" or end with a period.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;homepage&lt;/code&gt; — your project's URL. Required.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;version "0.7.1"&lt;/code&gt; — the version string, no leading &lt;code&gt;v&lt;/code&gt;. Homebrew can often infer this from the &lt;code&gt;url&lt;/code&gt;, but stating it explicitly makes the auto-update rewrite (Step 4) a clean find-and-replace.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;url&lt;/code&gt; — the direct download link to the release asset. This is the load-bearing line: it points at the exact file the release workflow attached. The pattern is &lt;code&gt;.../releases/download/&amp;lt;tag&amp;gt;/&amp;lt;asset-name&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sha256&lt;/code&gt; — the SHA-256 of the file at &lt;code&gt;url&lt;/code&gt;. Homebrew downloads the tarball and refuses to install if the hash doesn't match, which is what protects users from a corrupted or swapped asset. The 64 zeros above are a placeholder; the next section computes the real value.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;license "Apache-2.0"&lt;/code&gt; — the SPDX license identifier. &lt;code&gt;brew audit&lt;/code&gt; checks it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;def install ... end&lt;/code&gt; — the install block. &lt;code&gt;bin.install "timeglyph"&lt;/code&gt; takes the file named &lt;code&gt;timeglyph&lt;/code&gt; from the unpacked tarball and installs it into Homebrew's &lt;code&gt;bin&lt;/code&gt; directory (which is on the user's PATH). If your tarball nests the binary in a subdirectory, give the path: &lt;code&gt;bin.install "timeglyph-0.7.1-aarch64-apple-darwin/timeglyph"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test do ... end&lt;/code&gt; — runs on &lt;code&gt;brew test timeglyph&lt;/code&gt; and during &lt;code&gt;brew audit&lt;/code&gt;. &lt;code&gt;shell_output&lt;/code&gt; runs the command and captures stdout; &lt;code&gt;assert_match&lt;/code&gt; fails if the expected string isn't present. Here it confirms &lt;code&gt;timeglyph --version&lt;/code&gt; runs and identifies itself. A formula with no real test is a formula nobody can verify, so write one that actually exercises the binary — the full formula below adds a second assertion that decodes a known timestamp and checks the answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3 — compute the sha256
&lt;/h2&gt;

&lt;p&gt;Download the exact asset and hash it. On macOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; timeglyph-0.7.1-aarch64-apple-darwin.tar.gz &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-apple-darwin.tar.gz
shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256 timeglyph-0.7.1-aarch64-apple-darwin.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shasum -a 256&lt;/code&gt; prints the 64-character hex digest followed by the filename — for this asset, &lt;code&gt;d686ff4c8dcf4167333257041e60a1a0d85d291efbb208b588018d54db41d6cf&lt;/code&gt;. Copy the digest into the &lt;code&gt;sha256&lt;/code&gt; line, replacing the zeros. (Linux users: &lt;code&gt;sha256sum&lt;/code&gt; instead of &lt;code&gt;shasum -a 256&lt;/code&gt;. Homebrew also bundles its own copy: &lt;code&gt;brew install --interactive&lt;/code&gt; and friends, but &lt;code&gt;shasum&lt;/code&gt; is simplest.)&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-L&lt;/code&gt; on &lt;code&gt;curl&lt;/code&gt; matters — GitHub release downloads redirect, and without &lt;code&gt;-L&lt;/code&gt; you'll hash a tiny redirect page instead of the tarball. If your computed hash looks wrong, check that the downloaded file is actually megabytes, not a few hundred bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpoint:&lt;/strong&gt; the file on disk is the real binary tarball (check its size), and the &lt;code&gt;sha256&lt;/code&gt; line now holds its digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — supporting both Apple Silicon and Intel
&lt;/h2&gt;

&lt;p&gt;Macs come in two architectures now: Apple Silicon (arm64) and older Intel (x86_64) — and Homebrew also runs on Linux, where the same formula can serve the musl builds. If your release workflow builds all four targets (the matrix in the map does), your formula hands each user the right binary. Here is the file as it actually ships today — &lt;code&gt;Formula/timeglyph.rb&lt;/code&gt; in &lt;a href="https://github.com/SecurityRonin/homebrew-tap" rel="noopener noreferrer"&gt;SecurityRonin/homebrew-tap&lt;/a&gt;, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Timeglyph&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Formula&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Forensic timestamp decipherment — scored, cited, ambiguity-first"&lt;/span&gt;
  &lt;span class="n"&gt;homepage&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/SecurityRonin/timeglyph"&lt;/span&gt;
  &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s2"&gt;"0.7.1"&lt;/span&gt;
  &lt;span class="n"&gt;license&lt;/span&gt; &lt;span class="s2"&gt;"Apache-2.0"&lt;/span&gt;

  &lt;span class="n"&gt;on_macos&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;Hardware&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CPU&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arm?&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-apple-darwin.tar.gz"&lt;/span&gt;
      &lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="s2"&gt;"d686ff4c8dcf4167333257041e60a1a0d85d291efbb208b588018d54db41d6cf"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-x86_64-apple-darwin.tar.gz"&lt;/span&gt;
      &lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="s2"&gt;"d185000c02d29d2a050e32919ea1b5856dd64aec04ecd881a0a5b6537dae644c"&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;on_linux&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;Hardware&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CPU&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arm?&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-unknown-linux-musl.tar.gz"&lt;/span&gt;
      &lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="s2"&gt;"54023ed5f1a80471a51e7ae4adb78ed8a39d7aef0ec47b0408846c21bd4abeff"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-x86_64-unknown-linux-musl.tar.gz"&lt;/span&gt;
      &lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="s2"&gt;"9bf6fd1da0be4e957dbe9fe2f0b1939e0d2456044b8e0bd98755fb5e29b04663"&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;install&lt;/span&gt;
    &lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"timeglyph"&lt;/span&gt;
    &lt;span class="c1"&gt;# The macOS archive also carries the lens GUI (Linux is CLI-only).&lt;/span&gt;
    &lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"timeglyph-lens"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;OS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mac?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;assert_match&lt;/span&gt; &lt;span class="s2"&gt;"timeglyph"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shell_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/timeglyph --version"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;assert_match&lt;/span&gt; &lt;span class="s2"&gt;"2020-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shell_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/timeglyph decode unix 1577836800"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four URLs, four hashes, one per platform-architecture pair: &lt;code&gt;on_macos&lt;/code&gt;/&lt;code&gt;on_linux&lt;/code&gt; pick the OS, &lt;code&gt;Hardware::CPU.arm?&lt;/code&gt; picks the architecture inside each. Compute each &lt;code&gt;sha256&lt;/code&gt; from its own asset (Step 3, four times — or read them all from the release's &lt;code&gt;checksums.txt&lt;/code&gt;, which is what the automation in Step 6 does). &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;desc&lt;/code&gt;, &lt;code&gt;install&lt;/code&gt;, and &lt;code&gt;test&lt;/code&gt; stay shared. Note the second &lt;code&gt;test&lt;/code&gt; assertion: decoding the Unix timestamp &lt;code&gt;1577836800&lt;/code&gt; must print &lt;code&gt;2020-01-01&lt;/code&gt; — a real functional check, not just a version string. Don't reach for anything fancier than this structure — two nested branches is the whole job, and over-engineering the selection logic is its own footgun.&lt;/p&gt;

&lt;p&gt;Commit this file to &lt;code&gt;Formula/timeglyph.rb&lt;/code&gt; in the tap. That's the seed done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — verify the seed installs
&lt;/h2&gt;

&lt;p&gt;Before automating anything, confirm the hand-written formula works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;SecurityRonin/tap/timeglyph
timeglyph &lt;span class="nt"&gt;--version&lt;/span&gt;
brew audit &lt;span class="nt"&gt;--strict&lt;/span&gt; SecurityRonin/tap/timeglyph
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;brew install&lt;/code&gt; should download the asset matching your Mac's architecture, verify the sha256, and drop &lt;code&gt;timeglyph&lt;/code&gt; on your PATH. Running &lt;code&gt;timeglyph --version&lt;/code&gt; proves it landed. &lt;code&gt;brew audit --strict&lt;/code&gt; runs Homebrew's own lint over the formula and flags style problems before users hit them. (These are live commands — the tap and formula are public, so you can run all three right now and watch them pass.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpoint:&lt;/strong&gt; all three commands succeed. If &lt;code&gt;brew install&lt;/code&gt; fails on the sha256, you hashed the wrong file or the wrong architecture's asset. If it fails on &lt;code&gt;bin.install&lt;/code&gt;, the binary's name or path inside the tarball doesn't match — unpack the tarball locally and look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — wire the auto-update so you never edit the formula by hand again
&lt;/h2&gt;

&lt;p&gt;Editing the formula by hand on every release is the manual step we're eliminating. The mechanism is GitHub's &lt;strong&gt;&lt;code&gt;repository_dispatch&lt;/code&gt;&lt;/strong&gt;: one repo (your app's release workflow) sends a custom event to another repo (the tap), and a workflow in the tap wakes up and does the work.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flcyo3fa1wbcjla1e04el.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flcyo3fa1wbcjla1e04el.png" alt="release.yml dispatches a repository_dispatch event to a matching handler workflow in the tap, which reads the release's checksums.txt and commits the rewritten formula" width="552" height="1630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are two halves: the &lt;strong&gt;dispatch&lt;/strong&gt; (in your app repo's &lt;code&gt;release.yml&lt;/code&gt;) and the &lt;strong&gt;handler&lt;/strong&gt; (in the tap).&lt;/p&gt;

&lt;h3&gt;
  
  
  The dispatch step, in the app's release.yml
&lt;/h3&gt;

&lt;p&gt;After the release job has created the GitHub Release with its assets, add a step that pokes the tap. This step lives in the same &lt;code&gt;release.yml&lt;/code&gt; that the &lt;em&gt;one-tag workflow&lt;/em&gt; fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0&lt;/span&gt; &lt;span class="c1"&gt;# v3.0.0&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.TAP_GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityRonin/homebrew-tap&lt;/span&gt;
      &lt;span class="na"&gt;event-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;update-timeglyph&lt;/span&gt;
      &lt;span class="na"&gt;client-payload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{"version":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.ref_name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is timeglyph's actual dispatch step. Under the hood it is a single HTTP POST, which you could equally send yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/vnd.github+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;token-with-tap-write-access&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://api.github.com/repos/SecurityRonin/homebrew-tap/dispatches &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"event_type":"update-timeglyph","client_payload":{"version":"v0.7.1"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two parts that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;event-type: update-timeglyph&lt;/code&gt;&lt;/strong&gt; — a name unique to &lt;em&gt;this&lt;/em&gt; project. Not &lt;code&gt;update-formula&lt;/code&gt;. Not &lt;code&gt;update&lt;/code&gt;. The handler in the tap listens for exactly this string. (This is the second footgun, and it has its own section below.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;client_payload&lt;/code&gt;&lt;/strong&gt; — arbitrary JSON you pass along. Here we send the tag (&lt;code&gt;github.ref_name&lt;/code&gt;, e.g. &lt;code&gt;v0.7.1&lt;/code&gt;) so the handler knows which release to read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;secrets.TAP_GITHUB_TOKEN&lt;/code&gt; is a credential with write access to the tap. That's the third footgun, also below. (One reason to prefer the action over a hand-rolled &lt;code&gt;curl&lt;/code&gt;: the action fails the job on a 403, while a bare &lt;code&gt;curl&lt;/code&gt; without &lt;code&gt;--fail&lt;/code&gt; exits 0 and hides the problem.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The handler workflow, in the tap
&lt;/h3&gt;

&lt;p&gt;In the tap repo, create &lt;code&gt;.github/workflows/update-timeglyph.yml&lt;/code&gt;. It triggers on the matching &lt;code&gt;event_type&lt;/code&gt; and regenerates the formula. This is the live handler from &lt;a href="https://github.com/SecurityRonin/homebrew-tap" rel="noopener noreferrer"&gt;SecurityRonin/homebrew-tap&lt;/a&gt;, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update timeglyph formula&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;repository_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;update-timeglyph&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Version&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;update&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(e.g.,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;v0.2.0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0.2.0)'&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;update-formula&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set version from event&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;get-version&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;if [ "${{ github.event_name }}" = "repository_dispatch" ]; then&lt;/span&gt;
            &lt;span class="s"&gt;RAW="${{ github.event.client_payload.version }}"&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;RAW="${{ inputs.version }}"&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
          &lt;span class="s"&gt;# Strip leading 'v' if present (tags are v0.2.0, formula wants 0.2.0)&lt;/span&gt;
          &lt;span class="s"&gt;VERSION="${RAW#v}"&lt;/span&gt;
          &lt;span class="s"&gt;echo "version=${VERSION}" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Download checksums from release&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;VERSION="${{ steps.get-version.outputs.version }}"&lt;/span&gt;
          &lt;span class="s"&gt;echo "Downloading checksums for v${VERSION}..."&lt;/span&gt;
          &lt;span class="s"&gt;gh release download "v${VERSION}" \&lt;/span&gt;
            &lt;span class="s"&gt;--repo SecurityRonin/timeglyph \&lt;/span&gt;
            &lt;span class="s"&gt;--pattern "checksums.txt"&lt;/span&gt;
          &lt;span class="s"&gt;cat checksums.txt&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Extract SHA256 checksums&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checksums&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;extract_sha() {&lt;/span&gt;
            &lt;span class="s"&gt;grep "$1" checksums.txt | awk '{print $1}'&lt;/span&gt;
          &lt;span class="s"&gt;}&lt;/span&gt;

          &lt;span class="s"&gt;VERSION="${{ steps.get-version.outputs.version }}"&lt;/span&gt;
          &lt;span class="s"&gt;echo "arm64_macos_sha=$(extract_sha "timeglyph-${VERSION}-aarch64-apple-darwin.tar.gz")" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "x86_64_macos_sha=$(extract_sha "timeglyph-${VERSION}-x86_64-apple-darwin.tar.gz")" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "arm64_linux_sha=$(extract_sha "timeglyph-${VERSION}-aarch64-unknown-linux-musl.tar.gz")" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "x86_64_linux_sha=$(extract_sha "timeglyph-${VERSION}-x86_64-unknown-linux-musl.tar.gz")" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update Formula&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;VERSION="${{ steps.get-version.outputs.version }}"&lt;/span&gt;
          &lt;span class="s"&gt;ARM64_MACOS="${{ steps.checksums.outputs.arm64_macos_sha }}"&lt;/span&gt;
          &lt;span class="s"&gt;X86_64_MACOS="${{ steps.checksums.outputs.x86_64_macos_sha }}"&lt;/span&gt;
          &lt;span class="s"&gt;ARM64_LINUX="${{ steps.checksums.outputs.arm64_linux_sha }}"&lt;/span&gt;
          &lt;span class="s"&gt;X86_64_LINUX="${{ steps.checksums.outputs.x86_64_linux_sha }}"&lt;/span&gt;

          &lt;span class="s"&gt;cat &amp;gt; Formula/timeglyph.rb &amp;lt;&amp;lt; FORMULA&lt;/span&gt;
          &lt;span class="s"&gt;class Timeglyph &amp;lt; Formula&lt;/span&gt;
            &lt;span class="s"&gt;desc "Forensic timestamp decipherment — scored, cited, ambiguity-first"&lt;/span&gt;
            &lt;span class="s"&gt;homepage "https://github.com/SecurityRonin/timeglyph"&lt;/span&gt;
            &lt;span class="s"&gt;version "${VERSION}"&lt;/span&gt;
            &lt;span class="s"&gt;license "Apache-2.0"&lt;/span&gt;

            &lt;span class="s"&gt;on_macos do&lt;/span&gt;
              &lt;span class="s"&gt;if Hardware::CPU.arm?&lt;/span&gt;
                &lt;span class="s"&gt;url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-aarch64-apple-darwin.tar.gz"&lt;/span&gt;
                &lt;span class="s"&gt;sha256 "${ARM64_MACOS}"&lt;/span&gt;
              &lt;span class="s"&gt;else&lt;/span&gt;
                &lt;span class="s"&gt;url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-x86_64-apple-darwin.tar.gz"&lt;/span&gt;
                &lt;span class="s"&gt;sha256 "${X86_64_MACOS}"&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
            &lt;span class="s"&gt;end&lt;/span&gt;

            &lt;span class="s"&gt;on_linux do&lt;/span&gt;
              &lt;span class="s"&gt;if Hardware::CPU.arm?&lt;/span&gt;
                &lt;span class="s"&gt;url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-aarch64-unknown-linux-musl.tar.gz"&lt;/span&gt;
                &lt;span class="s"&gt;sha256 "${ARM64_LINUX}"&lt;/span&gt;
              &lt;span class="s"&gt;else&lt;/span&gt;
                &lt;span class="s"&gt;url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-x86_64-unknown-linux-musl.tar.gz"&lt;/span&gt;
                &lt;span class="s"&gt;sha256 "${X86_64_LINUX}"&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
            &lt;span class="s"&gt;end&lt;/span&gt;

            &lt;span class="s"&gt;def install&lt;/span&gt;
              &lt;span class="s"&gt;bin.install "timeglyph"&lt;/span&gt;
              &lt;span class="s"&gt;# The macOS archive also carries the lens GUI (Linux is CLI-only).&lt;/span&gt;
              &lt;span class="s"&gt;bin.install "timeglyph-lens" if OS.mac?&lt;/span&gt;
            &lt;span class="s"&gt;end&lt;/span&gt;

            &lt;span class="s"&gt;test do&lt;/span&gt;
              &lt;span class="s"&gt;assert_match "timeglyph", shell_output("#{bin}/timeglyph --version")&lt;/span&gt;
              &lt;span class="s"&gt;assert_match "2020-01-01", shell_output("#{bin}/timeglyph decode unix 1577836800")&lt;/span&gt;
            &lt;span class="s"&gt;end&lt;/span&gt;
          &lt;span class="s"&gt;end&lt;/span&gt;
          &lt;span class="s"&gt;FORMULA&lt;/span&gt;

          &lt;span class="s"&gt;# Remove leading whitespace from heredoc&lt;/span&gt;
          &lt;span class="s"&gt;sed -i 's/^          //' Formula/timeglyph.rb&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Commit and push&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;VERSION="${{ steps.get-version.outputs.version }}"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "github-actions[bot]"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "github-actions[bot]@users.noreply.github.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add Formula/timeglyph.rb&lt;/span&gt;
          &lt;span class="s"&gt;git diff --cached --quiet &amp;amp;&amp;amp; echo "No changes to commit" &amp;amp;&amp;amp; exit 0&lt;/span&gt;
          &lt;span class="s"&gt;git commit -m "timeglyph: update to v${VERSION}"&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it does, in order: reads the version from the dispatch payload (or from a manual &lt;code&gt;workflow_dispatch&lt;/code&gt; input — a convenience trigger worth having, so you can re-run any version by hand), checks out the tap, downloads the release's &lt;code&gt;checksums.txt&lt;/code&gt; (the release workflow already computed every asset's digest — no need to re-download the tarballs and hash them here), extracts the four digests, writes a fresh &lt;code&gt;Formula/timeglyph.rb&lt;/code&gt;, then commits and pushes. The &lt;code&gt;git diff --cached --quiet&lt;/code&gt; guard makes a re-run of an already-applied version a clean no-op instead of an empty-commit failure.&lt;/p&gt;

&lt;p&gt;One subtlety in that heredoc. The shell variables (&lt;code&gt;${VERSION}&lt;/code&gt;, &lt;code&gt;${ARM64_MACOS}&lt;/code&gt;) must be expanded, so the heredoc marker is unquoted. But &lt;code&gt;#{bin}&lt;/code&gt; and &lt;code&gt;#{version}&lt;/code&gt; inside the Ruby &lt;code&gt;test&lt;/code&gt; block are Homebrew interpolations, not shell ones — they have to reach the file literally. An unquoted heredoc leaves &lt;code&gt;#{...}&lt;/code&gt; alone (the shell only touches &lt;code&gt;$&lt;/code&gt;-prefixed names), so this works as written. The &lt;code&gt;sed&lt;/code&gt; afterwards strips the ten-space YAML indent from every line, because the heredoc body is indented to sit inside the workflow file but the committed formula must start at column zero. Either way, run the handler once on a real tag and read the committed file to confirm the hashes, the indentation, and the literal &lt;code&gt;#{version}&lt;/code&gt; all survived.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpoint:&lt;/strong&gt; push a new tag to the app repo, watch &lt;code&gt;release.yml&lt;/code&gt; run its dispatch step, then watch the &lt;code&gt;update-timeglyph&lt;/code&gt; workflow appear and run in the tap. The result is a new commit on the tap bumping the formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footgun 1 — a shared event type fires the wrong project's updater
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;repository_dispatch&lt;/code&gt; routes purely on the &lt;code&gt;event_type&lt;/code&gt; string. If two projects both dispatch &lt;code&gt;update-formula&lt;/code&gt;, both handler workflows fire on &lt;em&gt;every&lt;/em&gt; dispatch — so your &lt;code&gt;timeglyph&lt;/code&gt; release triggers the &lt;code&gt;other-tool&lt;/code&gt; updater, which dutifully rebuilds &lt;code&gt;other-tool&lt;/code&gt;'s formula from &lt;code&gt;other-tool&lt;/code&gt;'s latest release. Nothing errors. The wrong formula just churns, and the right one might too, and you spend an afternoon wondering why.&lt;/p&gt;

&lt;p&gt;The fix is mechanical: &lt;strong&gt;one event type per project, named after the project&lt;/strong&gt;, and a handler whose &lt;code&gt;types:&lt;/code&gt; filter matches exactly that string.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App &lt;code&gt;timeglyph&lt;/code&gt; dispatches &lt;code&gt;update-timeglyph&lt;/code&gt; → tap workflow &lt;code&gt;update-timeglyph.yml&lt;/code&gt; listens for &lt;code&gt;update-timeglyph&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;App &lt;code&gt;other-tool&lt;/code&gt; dispatches &lt;code&gt;update-other-tool&lt;/code&gt; → tap workflow &lt;code&gt;update-other-tool.yml&lt;/code&gt; listens for &lt;code&gt;update-other-tool&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Distinct strings, distinct handlers, no crosstalk. The tap can hold a dozen handler workflows; each one only wakes for its own event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footgun 2 — the dispatching bot needs write access, or it 403s silently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;repository_dispatch&lt;/code&gt; is a write operation against the tap. The default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; in your app's &lt;code&gt;release.yml&lt;/code&gt; is scoped to the &lt;em&gt;app&lt;/em&gt; repo — it has no permission on the separate tap repo. Use it to dispatch to the tap and GitHub returns &lt;strong&gt;403 Forbidden&lt;/strong&gt; and does nothing. Here's the trap: a bare &lt;code&gt;curl&lt;/code&gt; that gets a 403 still exits 0 by default, so the release job stays green. The release looks shipped. The tap never moves.&lt;/p&gt;

&lt;p&gt;The fix is a credential that genuinely has write access to the tap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a fine-grained personal access token&lt;/strong&gt; (or a classic PAT with &lt;code&gt;repo&lt;/code&gt; scope) on an account that is a write collaborator on &lt;code&gt;SecurityRonin/homebrew-tap&lt;/code&gt;. For a fine-grained token, scope it to the &lt;code&gt;homebrew-tap&lt;/code&gt; repository with &lt;strong&gt;Contents: read and write&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store it as a secret in the app repo&lt;/strong&gt; named &lt;code&gt;TAP_GITHUB_TOKEN&lt;/code&gt; (per the map's advice, prefer an &lt;em&gt;organization&lt;/em&gt; secret so every app that dispatches inherits it and rotation is one update). Reference it as &lt;code&gt;${{ secrets.TAP_GITHUB_TOKEN }}&lt;/code&gt; in the dispatch step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm write access&lt;/strong&gt; before trusting it. Run the dispatch &lt;code&gt;curl&lt;/code&gt; manually with &lt;code&gt;-i&lt;/code&gt; to see the status line:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;the-token&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     https://api.github.com/repos/SecurityRonin/homebrew-tap/dispatches &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"event_type":"update-timeglyph"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A working token returns &lt;code&gt;204 No Content&lt;/code&gt;. A &lt;code&gt;403&lt;/code&gt; means the token's account isn't a write collaborator on the tap, or the token's scope doesn't include Contents: write. Fix that before you rely on it — and add &lt;code&gt;--fail&lt;/code&gt; to the &lt;code&gt;curl&lt;/code&gt; in your workflow so a future 403 turns the release red instead of hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it actually worked
&lt;/h2&gt;

&lt;p&gt;Don't trust green. Confirm the end-to-end path with commands a user would run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew untap SecurityRonin/tap 2&amp;gt;/dev/null   &lt;span class="c"&gt;# start clean&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;SecurityRonin/tap/timeglyph
brew audit &lt;span class="nt"&gt;--strict&lt;/span&gt; SecurityRonin/tap/timeglyph
timeglyph &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;brew install SecurityRonin/tap/timeglyph&lt;/code&gt; resolving and downloading proves the tap, the formula name, the &lt;code&gt;url&lt;/code&gt;, and the &lt;code&gt;sha256&lt;/code&gt; all line up.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;brew audit --strict&lt;/code&gt; passing proves the formula meets Homebrew's own conventions — the thing that would block it from a future official submission.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timeglyph --version&lt;/code&gt; printing the version you just released proves the binary inside the tarball is the right one, installed on PATH.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, separately, confirm the automation: push a fresh tag to the app, wait for &lt;code&gt;release.yml&lt;/code&gt; to finish, and check the tap for a new commit bumping the formula. Open the committed &lt;code&gt;Formula/timeglyph.rb&lt;/code&gt; and read the version and all four &lt;code&gt;sha256&lt;/code&gt; lines — they should match the new release's assets. A green workflow is necessary but not sufficient; the proof is the formula file changed and still installs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping a GUI cask, not just a CLI formula
&lt;/h2&gt;

&lt;p&gt;Everything above ships a &lt;strong&gt;CLI as a formula&lt;/strong&gt; — &lt;code&gt;brew install SecurityRonin/tap/timeglyph&lt;/code&gt; — and needs no signing at all. Files Homebrew fetches with &lt;code&gt;curl&lt;/code&gt; and unpacks are never quarantined, and a command-line tool run from a terminal never triggers Gatekeeper's notarization check. That is the entire reason an unsigned CLI formula just works. No Apple account, nothing.&lt;/p&gt;

&lt;p&gt;There is a middle ground worth knowing: timeglyph's own GUI, the &lt;code&gt;timeglyph-lens&lt;/code&gt; overlay, ships &lt;em&gt;inside the formula&lt;/em&gt; as a second plain binary (&lt;code&gt;bin.install "timeglyph-lens" if OS.mac?&lt;/code&gt;). Launched from the terminal like any CLI, it stays on the no-signing path above. That trick holds exactly as long as you don't need a real &lt;code&gt;.app&lt;/code&gt; bundle in &lt;code&gt;/Applications&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;.app&lt;/code&gt; is the other case. When your tool grows into a double-clickable desktop app, you ship it as a &lt;strong&gt;cask&lt;/strong&gt; — &lt;code&gt;brew install --cask yourorg/tap/yourapp&lt;/code&gt; — and a cask installs a &lt;code&gt;.app&lt;/code&gt;. A &lt;code&gt;.app&lt;/code&gt; installed by a cask &lt;em&gt;is&lt;/em&gt; quarantined (&lt;code&gt;com.apple.quarantine&lt;/code&gt;) and launched from Finder, so it hits the &lt;strong&gt;full Gatekeeper wall&lt;/strong&gt;. On Apple Silicon an unsigned or ad-hoc-signed app shows the harsh &lt;strong&gt;"'App' is damaged and can't be opened"&lt;/strong&gt; message — not the milder "unidentified developer" with a right-click→Open escape. Homebrew is also &lt;strong&gt;dropping support for unsigned casks (2026-09-01)&lt;/strong&gt;, so signing the app isn't optional if you want the cask to survive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is &lt;em&gt;not&lt;/em&gt; enough&lt;/strong&gt; (learn this before you waste a release):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ad-hoc / linker signature&lt;/strong&gt; — the arm64 linker auto-applies &lt;code&gt;Signature=adhoc, TeamIdentifier=not set&lt;/code&gt;. &lt;code&gt;spctl -a -t exec -vvv&lt;/code&gt; still says &lt;strong&gt;rejected&lt;/strong&gt;. The app runs on &lt;em&gt;your&lt;/em&gt; Mac (no quarantine there) but is blocked on anyone's download.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer-ID-signed but &lt;em&gt;not&lt;/em&gt; notarized&lt;/strong&gt; — also blocked, since macOS Catalina. Signing alone is insufficient; notarization is a separate Apple-side scan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a cask needs the full chain: &lt;strong&gt;Developer ID Application signature → notarize → staple.&lt;/strong&gt; That needs an &lt;strong&gt;Apple Developer Program membership&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you have been through &lt;a href="https://dev.to/4n6h4x0r/winget-from-zero-the-manual-first-submission-then-auto-updates-5do7"&gt;winget from zero&lt;/a&gt;, this is the same wall on a different OS. Gatekeeper is macOS playing the role SmartScreen plays on Windows, and Developer ID + notarization is the counterpart of &lt;strong&gt;Authenticode&lt;/strong&gt; via Azure Trusted Signing: an unsigned artifact gets the scary block, a signed one from a validated organization sails through, and in both cases the check happens on the &lt;em&gt;user's&lt;/em&gt; machine against the &lt;em&gt;downloaded&lt;/em&gt; bytes — which is why an app that runs fine locally proves nothing. The paperwork even overlaps: the &lt;strong&gt;D-U-N-S number&lt;/strong&gt; Dun &amp;amp; Bradstreet issued your organization for Azure's identity validation is the same one Apple's org enrollment asks for. One legal identity, validated once per platform, signs everything you ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-time setup
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apple Developer Program ($99/yr).&lt;/strong&gt; Enroll as the &lt;strong&gt;organization&lt;/strong&gt; (the same legal name you use for any other platform's publisher identity — one identity everywhere). Org enrollment needs a &lt;strong&gt;D-U-N-S number&lt;/strong&gt; (free from D&amp;amp;B, ~1–5 business days) and legal-entity verification. Note your &lt;strong&gt;Team ID&lt;/strong&gt; (10 chars, e.g. &lt;code&gt;AB12CD34EF&lt;/code&gt;). (Individual enrollment is faster but ships under a person's name.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Developer ID Application certificate.&lt;/strong&gt; developer.apple.com → Certificates → &lt;strong&gt;+&lt;/strong&gt; → &lt;strong&gt;Developer ID Application&lt;/strong&gt; → do the CSR dance in Keychain Access. Then &lt;strong&gt;export the cert &lt;em&gt;and&lt;/em&gt; its private key as a &lt;code&gt;.p12&lt;/code&gt;&lt;/strong&gt; with a strong password. Your identity string is &lt;code&gt;Developer ID Application: Your Org (TEAMID)&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Notarization API key.&lt;/strong&gt; appstoreconnect.apple.com → Users and Access → Integrations → &lt;strong&gt;App Store Connect API&lt;/strong&gt; → &lt;strong&gt;+&lt;/strong&gt; → role Developer → download the &lt;strong&gt;&lt;code&gt;AuthKey_XXXX.p8&lt;/code&gt;&lt;/strong&gt; (you get it &lt;em&gt;once&lt;/em&gt;). Record the &lt;strong&gt;Key ID&lt;/strong&gt; and the &lt;strong&gt;Issuer ID&lt;/strong&gt; (a UUID on that page). An API key beats an Apple-ID + app-specific-password because there's no 2FA to babysit in CI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Store six secrets&lt;/strong&gt; (base64 the binary ones):&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MACOS_CERT_P12_BASE64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;base64 -i cert.p12&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MACOS_CERT_PASSWORD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;.p12&lt;/code&gt; export password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MACOS_SIGN_IDENTITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Developer ID Application: Your Org (TEAMID)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MACOS_NOTARY_KEY_P8_BASE64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;base64 -i AuthKey_XXXX.p8&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MACOS_NOTARY_KEY_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the API Key ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MACOS_NOTARY_ISSUER_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the API Issuer ID (UUID)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A nice pattern: &lt;strong&gt;gate the macOS signing steps on &lt;code&gt;secrets.MACOS_CERT_P12_BASE64 != ''&lt;/code&gt;.&lt;/strong&gt; CI stays green before you've enrolled (the &lt;code&gt;.app&lt;/code&gt; ships unsigned and the cask waits as a draft PR); the moment all six secrets exist, the next release signs automatically. No workflow edit to "turn it on."&lt;/p&gt;

&lt;h3&gt;
  
  
  The CI signing flow (macOS runner)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. import the .p12 into a temporary keychain (delete it in a trap)&lt;/span&gt;
&lt;span class="c"&gt;# 2. sign with the hardened runtime + a secure timestamp:&lt;/span&gt;
codesign &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--deep&lt;/span&gt; &lt;span class="nt"&gt;--options&lt;/span&gt; runtime &lt;span class="nt"&gt;--timestamp&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sign&lt;/span&gt; &lt;span class="s2"&gt;"Developer ID Application: Your Org (TEAMID)"&lt;/span&gt; &lt;span class="s2"&gt;"YourApp.app"&lt;/span&gt;
codesign &lt;span class="nt"&gt;--verify&lt;/span&gt; &lt;span class="nt"&gt;--deep&lt;/span&gt; &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="s2"&gt;"YourApp.app"&lt;/span&gt;
&lt;span class="c"&gt;# 3. notarize (Apple scans it, ~1–5 min) — API key, not Apple ID:&lt;/span&gt;
xcrun notarytool submit &lt;span class="s2"&gt;"YourApp.app.zip"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; AuthKey.p8 &lt;span class="nt"&gt;--key-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--issuer&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ISSUER_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;span class="c"&gt;# 4. staple the ticket so offline Gatekeeper checks pass:&lt;/span&gt;
xcrun stapler staple &lt;span class="s2"&gt;"YourApp.app"&lt;/span&gt;
&lt;span class="c"&gt;# 5. ditto-zip the stapled .app for the cask&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verify a shipped release the way a user's Mac will&lt;/strong&gt;, not the way yours does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;codesign &lt;span class="nt"&gt;--verify&lt;/span&gt; &lt;span class="nt"&gt;--deep&lt;/span&gt; &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 /Applications/YourApp.app
spctl &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-vvv&lt;/span&gt; /Applications/YourApp.app   &lt;span class="c"&gt;# → "accepted, source=Notarized Developer ID"&lt;/span&gt;
xcrun stapler validate /Applications/YourApp.app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap mirrors the sha256 checkpoint above: &lt;strong&gt;an app that launches on your own Mac proves nothing&lt;/strong&gt; about a quarantined download on someone else's. Green locally is not green on download — always check &lt;code&gt;spctl&lt;/code&gt; on the shipped artifact, and "accepted, source=Notarized Developer ID" is the only pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bit me, and the fix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What bit me&lt;/th&gt;
&lt;th&gt;The fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Made one tap per tool, install commands all different&lt;/td&gt;
&lt;td&gt;One shared &lt;code&gt;homebrew-tap&lt;/code&gt;; every tool is &lt;code&gt;brew install yourorg/tap/&amp;lt;tool&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two projects shared event type &lt;code&gt;update-formula&lt;/code&gt;; one release fired the other's updater&lt;/td&gt;
&lt;td&gt;One event type per project (&lt;code&gt;update-timeglyph&lt;/code&gt;), handler &lt;code&gt;types:&lt;/code&gt; filter matches it exactly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dispatch returned 403 from a read-only token; release stayed green, tap never updated&lt;/td&gt;
&lt;td&gt;PAT with Contents: write on the tap, stored as a secret; add &lt;code&gt;--fail&lt;/code&gt; so a 403 turns the release red&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;brew install&lt;/code&gt; failed on sha256&lt;/td&gt;
&lt;td&gt;Hashed the wrong file — &lt;code&gt;curl -L&lt;/code&gt; (follow redirects) and hash the right architecture's asset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class name didn't match file name&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;report-cli.rb&lt;/code&gt; → &lt;code&gt;class ReportCli&lt;/code&gt;; hyphens drop, words capitalize&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;curl&lt;/code&gt; hashed a redirect page, not the tarball&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;-L&lt;/code&gt;; verify the downloaded file is megabytes, not bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heredoc ate the Ruby &lt;code&gt;#{version}&lt;/code&gt; in the test block&lt;/td&gt;
&lt;td&gt;Quote the heredoc marker or inject shell values with &lt;code&gt;sed&lt;/code&gt;; read the committed file to confirm the literal survived&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once the seed formula installs and the dispatch path is proven, Homebrew costs you nothing per release: the tag fires &lt;code&gt;release.yml&lt;/code&gt;, &lt;code&gt;release.yml&lt;/code&gt; dispatches &lt;code&gt;update-timeglyph&lt;/code&gt;, the handler rewrites the formula, and a Mac user's &lt;code&gt;brew install&lt;/code&gt; and &lt;code&gt;brew upgrade&lt;/code&gt; pick up the new version on their own.&lt;/p&gt;

</description>
      <category>homebrew</category>
      <category>releaseengineering</category>
    </item>
    <item>
      <title>The Half of Rust Nobody Teaches: Shipping</title>
      <dc:creator>Albert Hui</dc:creator>
      <pubDate>Wed, 24 Jun 2026 00:24:34 +0000</pubDate>
      <link>https://dev.to/4n6h4x0r/the-half-of-rust-nobody-teaches-shipping-56em</link>
      <guid>https://dev.to/4n6h4x0r/the-half-of-rust-nobody-teaches-shipping-56em</guid>
      <description>&lt;p&gt;Every Rust tutorial ends the same way: &lt;code&gt;cargo build --release&lt;/code&gt;, a victory screenshot of a binary running in a terminal, the end. The Book, the YouTube series, the "build X in Rust" posts — all of them stop at the moment the code works on the author's laptop.&lt;/p&gt;

&lt;p&gt;That is roughly the halfway point.&lt;/p&gt;

&lt;p&gt;The other half — turning a binary that works &lt;em&gt;for you&lt;/em&gt; into something a stranger can &lt;code&gt;brew install&lt;/code&gt; on a Mac, &lt;code&gt;winget install&lt;/code&gt; on Windows, or &lt;code&gt;apt install&lt;/code&gt; on Ubuntu, that updates itself, carries a version number that means something, and ships without you babysitting it — is a discipline of its own. It is closer to product management than to programming, and almost nobody writes it down. So here it is: the shipping half, from a real multi-crate app distributed across five platforms and four package managers.&lt;/p&gt;

&lt;p&gt;Consider this the &lt;strong&gt;map&lt;/strong&gt;. Each leg below — the architecture, the one-tag workflow, every package manager, the build cache, the cross-compile traps — is a deep-dive of its own, and each gets its own post in this series. Here we walk the whole territory at altitude, so the pieces connect before you zoom into any one of them. If you only read one thing about shipping Rust, read this; if you're about to &lt;em&gt;do&lt;/em&gt; it, follow the per-topic posts as they land.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture first: one big app is many small crates
&lt;/h2&gt;

&lt;p&gt;The instinct for a large Rust app is one giant crate. Resist it. The pattern that scales is a &lt;strong&gt;fleet&lt;/strong&gt;: a family of small, single-purpose library crates, each a deep expert in one thing, plus a thin application crate on top that wires them together.&lt;/p&gt;

&lt;p&gt;Two rules make this work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One concern per crate, with an honest name.&lt;/strong&gt; A crate name is read &lt;em&gt;bare&lt;/em&gt; — in &lt;code&gt;cargo add&lt;/code&gt;, in a dependency list, on crates.io search — stripped of all repo context. &lt;code&gt;mft-core&lt;/code&gt; tells you what it is; &lt;code&gt;utils&lt;/code&gt; does not. If your short prefix is distinctive (&lt;code&gt;memf-&lt;/code&gt;, &lt;code&gt;winevt-&lt;/code&gt;) it can stand alone; if it is a generic word (&lt;code&gt;browser-&lt;/code&gt;), keep the full descriptive prefix so the name claims a namespace by itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Depend down, never sideways or up.&lt;/strong&gt; Draw the layers and let dependencies only point downward. The app at the top imports everything; the leaf at the bottom imports nothing. When that arrow ever points the wrong way, you have found a design bug, not an inconvenience.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q9zcxlnxk273jda3f3a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q9zcxlnxk273jda3f3a.png" alt=" " width="800" height="353"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Arrows mean "depends on" — they only ever point down.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two policies pay for themselves later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batteries-included binaries.&lt;/strong&gt; A user in the field cannot &lt;code&gt;cargo build --features gpu,cloud&lt;/code&gt; on their machine. Compile every capability &lt;em&gt;in&lt;/em&gt;. If a heavy dependency trips a license or dependency gate, fix the &lt;em&gt;gate&lt;/em&gt;, not the feature set — amputating capability to slim a build ships a tool that silently can't do the thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lean core, full binary.&lt;/strong&gt; When one crate is both a heavy end-user tool &lt;em&gt;and&lt;/em&gt; something other libraries link for one primitive, split it: a lean &lt;code&gt;&amp;lt;x&amp;gt;-core&lt;/code&gt; with just the primitives (no GPU, no cloud), and the full &lt;code&gt;&amp;lt;x&amp;gt;&lt;/code&gt; binary that depends on it. Libraries link the lean core; the app stays batteries-included. One &lt;code&gt;default&lt;/code&gt; can't be both lean-for-libraries and full-for-the-binary — the split is the answer, not feature juggling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is visible in a tutorial because a tutorial has one crate and no consumers. At fleet scale it is the whole game.&lt;/p&gt;
&lt;h2&gt;
  
  
  One tag ships everything
&lt;/h2&gt;

&lt;p&gt;Here is the goal, and it is worth being strict about: &lt;strong&gt;a single signed git tag produces every artifact, on every platform, in every channel.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git tag &lt;span class="nt"&gt;-s&lt;/span&gt; v0.3.0 &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"v0.3.0"&lt;/span&gt;
git push origin v0.3.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tag triggers one &lt;code&gt;release.yml&lt;/code&gt; workflow that fans out to every platform and channel:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foy0bwgyty64c023hvusi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foy0bwgyty64c023hvusi.png" alt=" " width="800" height="834"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing else produces a release. If a repo's "tag" shows only GitHub's auto-generated source tarball, the workflow did not run — that auto-tarball is &lt;em&gt;not&lt;/em&gt; the release. The discipline is: one tag in, a full release out, and you &lt;strong&gt;verify it actually produced binaries&lt;/strong&gt; before telling anyone it shipped. A green push is not a green deploy.&lt;/p&gt;

&lt;p&gt;The build matrix is the spine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;aarch64-apple-darwin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;        &lt;span class="nv"&gt;os&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;macos-latest&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;x86_64-apple-darwin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;         &lt;span class="nv"&gt;os&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;macos-15&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;x86_64-unknown-linux-musl&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;   &lt;span class="nv"&gt;os&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ubuntu-latest&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;aarch64-unknown-linux-musl&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;  &lt;span class="nv"&gt;os&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ubuntu-latest&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;x86_64-pc-windows-msvc&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;      &lt;span class="nv"&gt;os&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;windows-latest&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The channels, and the part each one will bite you on
&lt;/h2&gt;

&lt;h3&gt;
  
  
  GitHub Releases
&lt;/h3&gt;

&lt;p&gt;The easy one, and the only one that needs &lt;em&gt;zero&lt;/em&gt; external secrets — the built-in &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is enough to attach binaries + checksums. If all you ever do is publish executables, you can stop here. Everything below is for reaching people who never visit your Releases page.&lt;/p&gt;

&lt;h3&gt;
  
  
  crates.io
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cargo publish&lt;/code&gt;, in dependency order, bottom of the graph up. Two rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A version publishes once.&lt;/strong&gt; Bump the version &lt;em&gt;before&lt;/em&gt; every release tag; a tag whose publish job re-pushes an existing version fails "already exists." Re-tagging the same &lt;code&gt;vX.Y.Z&lt;/code&gt; is only safe if the publish job never ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer the published registry crate over a &lt;code&gt;path&lt;/code&gt; dependency the moment it is on crates.io.&lt;/strong&gt; Path deps are for unpublished, in-flight work. Once your crate is published, switch dependents to &lt;code&gt;version = "0.2"&lt;/code&gt; so the build is reproducible and decoupled from your local checkout layout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an &lt;em&gt;application&lt;/em&gt; crate that depends on dozens of internal &lt;code&gt;path&lt;/code&gt; crates, crates.io publishing simply does not apply — an app ships as a binary, not a crate. Don't fight it; skip the publish job for the app and let the binary channels carry it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Homebrew
&lt;/h3&gt;

&lt;p&gt;One shared tap for the whole fleet (&lt;code&gt;brew install yourorg/tap/yourtool&lt;/code&gt;). The trap that costs an afternoon: &lt;strong&gt;each project must dispatch its &lt;em&gt;own&lt;/em&gt; event type&lt;/strong&gt; (&lt;code&gt;update-yourtool&lt;/code&gt;) to a &lt;em&gt;matching&lt;/em&gt; handler workflow in the tap. Share a generic &lt;code&gt;update-formula&lt;/code&gt; event between two projects and the second project's release fires the first project's updater. And the bot that dispatches needs &lt;strong&gt;write&lt;/strong&gt; access on the tap — &lt;code&gt;repository_dispatch&lt;/code&gt; from a read-only collaborator returns 403, silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  winget
&lt;/h3&gt;

&lt;p&gt;The single most surprising one: the &lt;code&gt;winget-releaser&lt;/code&gt; action &lt;strong&gt;cannot create a new package — it only bumps an existing one.&lt;/strong&gt; So your first version is a &lt;em&gt;manual&lt;/em&gt; submission (hand-author three YAML manifests, open a PR to &lt;code&gt;microsoft/winget-pkgs&lt;/code&gt;), and every release after that auto-PRs. Make the winget job &lt;code&gt;continue-on-error: true&lt;/code&gt; until that first manual PR merges, or it fails every release for no reason. Two more: keep the MSI's &lt;strong&gt;UpgradeCode stable across versions&lt;/strong&gt; (winget keys upgrades off it), and pull the &lt;strong&gt;ProductCode fresh each release&lt;/strong&gt; (it changes every build).&lt;/p&gt;

&lt;h3&gt;
  
  
  apt, via Cloudsmith
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;.deb&lt;/code&gt; packages built with &lt;code&gt;cargo-deb&lt;/code&gt;, pushed to a Cloudsmith repo, installed by users with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; https://dl.cloudsmith.io/public/yourorg/yourrepo/setup.deb.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gotcha is a 404: &lt;strong&gt;the Cloudsmith repo must exist before the first push.&lt;/strong&gt; Create it in the dashboard first; the workflow won't create it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas that are actually the product-management lessons
&lt;/h2&gt;

&lt;p&gt;These are the ones that don't appear in any "build a CLI in Rust" post, because they only exist once real people on real platforms install your thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;E0463: can't find crate for core&lt;/code&gt; on cross-compiles.&lt;/strong&gt; This means a &lt;code&gt;rust-toolchain.toml&lt;/code&gt; pin is overriding the toolchain your CI action installed, so the cross-target landed on the wrong toolchain. Pin the action to the &lt;em&gt;same&lt;/em&gt; version as the toml. It fails in ~20 seconds, before any real compilation, and a native &lt;code&gt;cargo publish --dry-run&lt;/code&gt; will never catch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cargo-deb&lt;/code&gt; errors "must have a copyright or authors property."&lt;/strong&gt; Add &lt;code&gt;authors = [...]&lt;/code&gt; to &lt;code&gt;[package]&lt;/code&gt;. One line, and it is the entire difference between a green and a red release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cargo-wix&lt;/code&gt; in a workspace where the binary is a member crate.&lt;/strong&gt; The trailing positional argument is parsed as the path to &lt;code&gt;Cargo.toml&lt;/code&gt;, &lt;em&gt;not&lt;/em&gt; the wxs input — so &lt;code&gt;cargo wix ... wix/main.wxs&lt;/code&gt; errors "does not appear to be a Cargo.toml file." Use &lt;code&gt;--package &amp;lt;crate&amp;gt; --include wix/main.wxs&lt;/code&gt; with repo-root-relative &lt;code&gt;Source=&lt;/code&gt; paths in the wxs. (And WiX is Windows-only; package the MSI on a Windows runner.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heavy C/C++ dependencies don't cross-compile to musl for free.&lt;/strong&gt; A bundled C++ engine (an embedded database, say) needs a C++ cross-compiler — and apt's &lt;code&gt;musl-tools&lt;/code&gt; ships &lt;code&gt;musl-gcc&lt;/code&gt; but no &lt;code&gt;musl-g++&lt;/code&gt;. Either switch the Linux targets to &lt;code&gt;-gnu&lt;/code&gt;, or use &lt;code&gt;cargo-zigbuild&lt;/code&gt; so &lt;code&gt;zig c++&lt;/code&gt; provides the musl toolchain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets are organization-level, and a repo-level secret silently shadows them.&lt;/strong&gt; Put your publish tokens (crates.io, the tap PAT, the winget PAT, the Cloudsmith key) as &lt;em&gt;organization&lt;/em&gt; secrets so every repo inherits them and rotation is one update. Then &lt;em&gt;delete the repo-level copies&lt;/em&gt; — a same-named repo secret overrides the org one, and the repo keeps using its stale local value while you wonder why the org rotation did nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit &lt;code&gt;Cargo.lock&lt;/code&gt; in every app/binary repo.&lt;/strong&gt; Otherwise CI resolves a fresh dependency graph that can differ from what you tested — and a release red-herring becomes a multi-hour misdiagnosis of a perfectly good crate.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the one about build times, because it will dominate your CI bill
&lt;/h2&gt;

&lt;p&gt;A real app with a bundled C++ dependency can take &lt;strong&gt;80+ minutes to compile on Windows MSVC&lt;/strong&gt; — three times the macOS clang time, because &lt;code&gt;cl.exe&lt;/code&gt; is slow on template-heavy C++ built as a few huge translation units. The fix is not a faster machine (those translation units don't parallelize); it is &lt;strong&gt;caching the build&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Swatinem/rust-cache&lt;/code&gt; caches &lt;code&gt;target/&lt;/code&gt;, which &lt;em&gt;includes&lt;/em&gt; the compiled C++ objects, so a warm build skips the recompile entirely — measured here as &lt;strong&gt;84 minutes down to 14&lt;/strong&gt;. Two things to know: it keys on the whole &lt;code&gt;Cargo.lock&lt;/code&gt; (so any dependency bump invalidates everything, including the unchanged C++), and it keys by &lt;em&gt;runner architecture&lt;/em&gt;, not the Rust &lt;code&gt;--target&lt;/code&gt; — so two matrix targets on the same runner collide on one cache key unless you add &lt;code&gt;key: ${{ matrix.target }}&lt;/code&gt;. The lock-key invalidation is the one real limitation — a dependency bump means a cold rebuild — and the obvious next move (a compiler-level cache like sccache) turned out to break this particular C++ build more than it helped, so rust-cache alone is the pragmatic answer here. The 10 GB GitHub Actions cache cap, by the way, is no longer hard — it is now a raisable, pay-as-you-go limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deep-dives ahead
&lt;/h2&gt;

&lt;p&gt;This was the map. Each leg is a full post of its own, landing in this series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The fleet architecture&lt;/strong&gt; — splitting one big app into many small crates, the depend-down discipline, and batteries-included vs. feature flags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One tag, every artifact&lt;/strong&gt; — the &lt;code&gt;release.yml&lt;/code&gt; anatomy, the build matrix, and verifying a release actually shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publishing to crates.io&lt;/strong&gt; — the lean-core/full-binary split, path-to-registry migration, and the publish-once rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/4n6h4x0r/homebrew-without-the-footguns-3ego"&gt;Homebrew without the footguns&lt;/a&gt;&lt;/strong&gt; — the shared tap, per-project dispatch handlers, and bot write access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/4n6h4x0r/winget-from-zero-the-manual-first-submission-then-auto-updates-5do7"&gt;winget from zero&lt;/a&gt;&lt;/strong&gt; — the manual first submission, then auto-updates, and keeping the MSI codes stable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;apt via Cloudsmith&lt;/strong&gt; — &lt;code&gt;.deb&lt;/code&gt; packaging and a one-line public install.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Windows MSI&lt;/strong&gt; — &lt;code&gt;cargo-wix&lt;/code&gt; in a virtual workspace, packaged on a Windows runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Making CI builds fast&lt;/strong&gt; — rust-cache, the cache-key collisions, the 84-to-14-minute story, and the caching layers that &lt;em&gt;don't&lt;/em&gt; pay off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cross-compile traps&lt;/strong&gt; — E0463, protoc, and musl-vs-gnu for C++ dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll link each here as it publishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping is a product discipline
&lt;/h2&gt;

&lt;p&gt;Here is the thing the tutorials are implicitly teaching by omission: that the code &lt;em&gt;is&lt;/em&gt; the product. It isn't. The product is the thing a stranger installs without reading your README, that works the first time, that updates itself, and that you can release again next week without remembering 14 manual steps.&lt;/p&gt;

&lt;p&gt;That is a product-management problem wearing an engineering costume. It has a roadmap (which channels, in what order), a definition of done (binaries verified live, not "the push was green"), a security posture (signed tags, org-scoped secrets, no insecure defaults), and a maintenance cost you pay every release. Treating it as an afterthought is why so much good Rust code never gets installed by anyone but its author.&lt;/p&gt;

&lt;p&gt;The good news: it is almost entirely a one-time setup. Build the fleet architecture, write the one tag-driven workflow, eat the gotchas above once, and from then on shipping to five platforms and four package managers costs you exactly one signed tag. That is the half nobody teaches — and it is the half that turns code into a tool people use.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>devops</category>
      <category>cargo</category>
      <category>cli</category>
    </item>
  </channel>
</rss>
