Skip to content

Build Plan: Windows packaging in release CI — scoop + winget + signed MSI #352

Description

@initializ-mk

Meta: Backlog build plan. Adds first-class Windows install parity (scoop + winget + signed MSI) to the release CI. Independent of the current onboarding/skill work; each of the three delivery paths is independently shippable. Flag the WiX licensing note (§3) to a maintainer before the MSI phase.

Build Plan: Windows Packaging in Release CI — scoop + winget + signed MSI

Target: Claude Code, direct execution.
Repo: github.com/initializ/forge. Existing release pipeline: tag push → GitHub Actions (.github/workflows/release.yaml) → GoReleaser builds platform binaries → GitHub Release → Homebrew tap update → multi-arch Docker image.
Goal of this change: give Windows users first-class install parity — winget install Initializ.Forge, scoop install forge, and a signed double-click MSI — with forge on PATH and an opt-in "Get Started" launch of forge try.

Do not touch the existing Homebrew, Docker, or install.sh jobs beyond adding new jobs alongside them.


1. Ordering and rationale (locked)

Ship in this order; each is independently shippable:

  1. scoop manifest — free via GoReleaser OSS, developer-native, PATH handled by scoop shims. Fastest win.
  2. signed MSI — built with WiX in a dedicated windows-latest CI job (GoReleaser's MSI pipe is Pro-only; WiX is free, mind the licensing note in §3). Serves the double-click and enterprise-fleet (Intune/SCCM/GPO) audiences.
  3. winget — published pointing at the signed MSI, via wingetcreate (first submission) then winget-releaser (subsequent). This is the brew install analog for the broad Windows dev audience.

Do not auto-run forge try from the installer. Use an opt-in finish-page checkbox plus a Start Menu shortcut that opens a fresh-PATH terminal running forge try. Reasons: MSI custom-action session/elevation context, PATH not visible to already-open shells, and installers that spawn network-active terminals trip AV/SmartScreen heuristics.


2. Scope exclusions

  • No MSIX / Microsoft Store package.
  • No Chocolatey.
  • No ARM64 Windows MSI (add later if demand appears; x64 only now).
  • No GoReleaser Pro (design assumes OSS; note the Pro shortcut where relevant).
  • No change to forge try behavior — it already resolves credentials gracefully (env → OAuth → Ollama → picker) and must not dead-end on first launch.

3. Prerequisites, secrets, and the licensing note

GitHub repo secrets to create (names are conventions; adapt to house style):

Secret Purpose
WINGET_TOKEN Classic PAT with public_repo scope. Fine-grained PATs are not supported by winget-releaser. Used to open the PR to microsoft/winget-pkgs.
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET Service principal for Azure Trusted Signing (renamed Azure Artifact Signing, Jan 2026). SP needs the "Trusted Signing Certificate Profile Signer" role on the signing account.
SCOOP_BUCKET_TOKEN PAT to push manifests to the scoop bucket repo (only if using a separate bucket repo; mirror the Homebrew tap pattern).

Signing account setup (one-time, outside CI): create an Azure Trusted/Artifact Signing account + certificate profile. Note the endpoint URL, account name, and cert profile name — you reference the profile name, not a cert file, because Microsoft rotates the underlying cert (short validity) and the timestamp keeps signatures valid after expiry. Reputation with SmartScreen still accrues over time; standard (non-EV) signing does not grant instant reputation.

Fork prerequisite for winget: fork microsoft/winget-pkgs under the initializ org (same org as this repo), so winget-releaser can push a branch.

WiX licensing note (flag to a human, do not silently accept): WiX v7 is the current toolset (.NET tool / MSBuild SDK). Its Open Source Maintenance Fee EULA requires a fee once the org makes ≥ US$10,000/year from projects using WiX, and v7 requires an explicit gesture to accept the EULA (fails the build otherwise). In CI, accept via the documented flag/env and ensure the maintenance fee / sponsorship is handled for commercial use. If the org prefers to avoid this, the alternative is GoReleaser Pro's MSI pipe (uses msitools under the hood) — call this out to the maintainer rather than deciding it here.


4. Reuse / verify inventory (confirm before editing)

Item Expected location Notes
GoReleaser config .goreleaser.yaml (confirm exact name/ext) Windows build target must exist: goos: [windows], goarch: [amd64], archive format zip. Add if missing.
Release workflow .github/workflows/release.yaml Add new jobs; do not rewrite existing ones.
Homebrew tap repo initializ/homebrew-tap Pattern to mirror for the scoop bucket.
Binary + version binary name forge; ldflags main.version, main.commit MSI ProductVersion must be numeric x.y.z — strip leading v and any prerelease/build metadata.
Tag format vX.Y.Z Source of the version passed to WiX and winget.

If any path/name differs, adapt — do not create duplicates.


5. Phases

Each phase must build/verify before the next. Stop on any failed verification and report.


Phase 1 — scoop manifest via GoReleaser OSS

Goal: scoop install forge works from an Initializ bucket, installing the Windows binary with PATH handled by scoop.

File Change
.goreleaser.yaml Confirm/add the Windows zip archive build. Add a scoops: block: bucket repo (initializ/scoop-bucket or an existing bucket), homepage, description, license, commit author, and SCOOP_BUCKET_TOKEN.
initializ/scoop-bucket (new repo, if not present) Create the bucket repo GoReleaser pushes manifests to.

Verification

# dry-run manifest generation, no publish:
goreleaser release --snapshot --clean --skip=publish
# inspect the generated scoop manifest under dist/
ls dist/ | grep -i scoop
# manual (Windows VM): add the bucket and install
#   scoop bucket add initializ https://github.com/initializ/scoop-bucket
#   scoop install forge ; forge --version ; where.exe forge

Anti-patterns

  • Do not point scoop at a Linux/Mac archive; it must consume the Windows zip.
  • Do not commit the bucket token; use the repo secret.
  • Do not rewrite unrelated GoReleaser sections.

Done when: a snapshot run emits a valid scoop manifest and a manual scoop install puts forge on PATH.


Phase 2 — WiX MSI project (PATH + Start Menu shortcut + opt-in launch)

Goal: a locally buildable, well-formed MSI that installs forge.exe, appends it to PATH, adds a "Forge — Get Started" Start Menu shortcut running forge try, and offers an opt-in finish-page checkbox to launch it. No signing yet.

File Change
packaging/windows/forge.wxs New. The WiX package (skeleton below).
packaging/windows/License.rtf New. Minimal RTF license for WixUI_InstallDir (or use a UI dialog set that omits the license page).
packaging/windows/build-msi.ps1 New. Helper: installs the WiX .NET tool + extensions, computes the numeric version, runs wix build.

forge.wxs skeleton (WiX v4–v7 schema; adapt names):

<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
     xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"
     xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util">
  <Package Name="Forge" Manufacturer="Initializ"
           Version="$(var.Version)" UpgradeCode="STABLE-GUID-GENERATE-ONCE"
           Scope="perMachine">
    <MajorUpgrade DowngradeErrorMessage="A newer version of Forge is already installed." />
    <MediaTemplate EmbedCab="yes" />

    <StandardDirectory Id="ProgramFiles6432Folder">
      <Directory Id="INSTALLFOLDER" Name="Forge">
        <Component Id="ForgeExe">
          <File Id="forgeExe" Source="$(var.BinPath)\forge.exe" KeyPath="yes" />
          <Environment Id="ForgePath" Name="PATH" Value="[INSTALLFOLDER]"
                       Action="set" Part="last" Permanent="no" System="yes" />
        </Component>
      </Directory>
    </StandardDirectory>

    <StandardDirectory Id="ProgramMenuFolder">
      <Component Id="GetStartedShortcut">
        <Shortcut Id="ForgeTry" Name="Forge — Get Started"
                  Target="[System64Folder]cmd.exe" Arguments='/k "forge try"' />
        <RemoveFolder Id="CleanupShortcut" On="uninstall" />
        <RegistryValue Root="HKCU" Key="Software\Initializ\Forge"
                       Name="installed" Type="integer" Value="1" KeyPath="yes" />
      </Component>
    </StandardDirectory>

    <Feature Id="Main">
      <ComponentRef Id="ForgeExe" />
      <ComponentRef Id="GetStartedShortcut" />
    </Feature>

    <!-- opt-in launch on finish -->
    <ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
    <WixVariable Id="WixUILicenseRtf" Value="packaging/windows/License.rtf" />
    <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
    <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Open Forge to get started" />
    <Property Id="WixShellExecTarget" Value="[System64Folder]cmd.exe" />
    <CustomAction Id="LaunchForgeTry" DllEntry="WixShellExec"
                  BinaryRef="Wix4UtilCA_X64" Impersonate="yes" Return="asyncNoWait" />
    <ui:Publish Dialog="ExitDialog" Control="Finish" Event="DoAction"
                Value="LaunchForgeTry"
                Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed" />
  </Package>
</Wix>

Notes for the implementer:

  • Version and BinPath are passed via -d Version=... -d BinPath=.... Strip v and prerelease/build metadata so Version is numeric x.y.z.
  • UpgradeCode is generated once and never changed. ProductCode is auto per version; MajorUpgrade cleans up old installs and their PATH entry.
  • perMachine gives a system PATH (one UAC prompt) and is what Intune/SCCM want. A per-user variant avoids UAC but only affects that user — note the tradeoff in a comment; default to perMachine.
  • The launch action is gated on the opt-in checkbox and a fresh (non-repair) install. The shortcut/cmd /k gives the user a fresh-PATH shell.

Verification (windows-latest or a Windows VM):

dotnet tool install --global wix --version 7.*
wix extension add -g WixToolset.UI.wixext
wix extension add -g WixToolset.Util.wixext
wix build packaging/windows/forge.wxs -d Version=1.2.3 -d BinPath=. `
  -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext -arch x64 -o forge.msi
msiexec /i forge.msi /qn        # silent install
# new shell:
where.exe forge ; forge --version   # expect resolved on PATH
# confirm Start Menu shortcut exists; then:
msiexec /x forge.msi /qn ; where.exe forge   # expect NOT found (PATH cleaned)

Anti-patterns

  • Never change UpgradeCode after the first shipped release.
  • Do not build the MSI on Linux/Mac (UI extension, custom actions, and later signing/testing need Windows).
  • Do not blind-launch: the DoAction must stay gated on the opt-in checkbox.
  • Do not append PATH manually in a custom action; use the Environment element (idempotent, uninstall-aware).

Done when: the MSI builds, installs forge onto PATH, adds the shortcut, the opt-in checkbox launches forge try, and uninstall removes both the binary and the PATH entry.


Phase 3 — Code signing (binary + MSI)

Goal: forge.exe and forge.msi carry a valid Authenticode signature so users don't hit the SmartScreen wall.

File Change
packaging/windows/build-msi.ps1 Add signing steps: sign forge.exe before packaging, build the MSI, then sign the MSI.

Approach: Azure Trusted/Artifact Signing via azure/trusted-signing-action (confirm current major version) or dotnet sign / signtool with the Trusted Signing dlib. Reference endpoint + account + cert profile name (not a file). Sign order: sign the binary, package it into the MSI, sign the MSI.

Verification

signtool verify /pa /v forge.exe
signtool verify /pa /v forge.msi   # expect a valid chain + timestamp

Anti-patterns

  • Never ship an unsigned MSI or unsigned forge.exe.
  • Never place a .pfx or secret in the repo; secrets come from GitHub Secrets / the SP.
  • Do not skip timestamping — it keeps signatures valid after the (short-lived) cert rotates.

Done when: both artifacts verify with a valid, timestamped signature.


Phase 4 — Release CI job (build + sign MSI, upload, publish to winget)

Goal: on a version tag, a windows-latest job builds and signs the MSI, attaches it to the GitHub Release, then publishes to winget.

File Change
.github/workflows/release.yaml New job windows-installer, runs-on: windows-latest, needs: the GoReleaser/release job so the release exists. Steps: checkout → download the released forge.exe/zip (or rebuild from the tag) → run build-msi.ps1 (Phase 2/3) → upload the signed MSI to the release → winget publish step.

winget publish:

  • First release only: seed Initializ.Forge in microsoft/winget-pkgs manually with wingetcreate (iwr https://aka.ms/wingetcreate/latest), pointing at the signed MSI URL. winget-releaser requires a base version to already exist.
  • Subsequent releases: add a step using vedantmgoyal9/winget-releaser@main (confirm current ref) with identifier: Initializ.Forge, installers-regex: '\.msi$', token: ${{ secrets.WINGET_TOKEN }}, triggered on release: [released]. Requires the initializ fork of winget-pkgs and the classic PAT.

Verification

# push a prerelease tag (e.g. v0.0.0-rc1) and watch the run:
#  - windows-installer job builds + signs the MSI
#  - MSI is attached to the release
#  - (dry-run first) winget step opens a PR to the fork, not straight to microsoft/winget-pkgs
# then on a Windows VM against the prerelease asset:
#  winget install --manifest <local-manifest>   # validates the manifest installs cleanly

Anti-patterns

  • Do not run winget-releaser before Initializ.Forge exists in winget-pkgs (seed with wingetcreate first).
  • Do not use a fine-grained PAT for winget-releaser (unsupported); classic PAT, public_repo scope.
  • Do not block the whole release if winget PR submission fails; make the winget step non-fatal and log it (the MSI + scoop must still ship).
  • Keep the MSI version numeric; reject prerelease metadata in ProductVersion.

Done when: a tagged release yields a signed MSI attached to the release plus a winget PR, with scoop already publishing from Phase 1, and all existing release jobs still green.


Phase 5 — Docs + install-path verification

Goal: the install docs lead with the Windows-native commands and the quick start's Windows line matches Mac's brew line.

File Change
docs/getting-started/installation.md Add a Windows section: winget install Initializ.Forge, scoop install forge, and the signed MSI download link. Note PATH is handled and, after MSI install, to open a new terminal for PATH to take effect.
docs/getting-started/quick-start.md Add the Windows one-liner next to the Mac/Linux one so forge try is one step from install on all three.
README.md Mirror the install matrix.
install.sh (repo root) Leave as-is (Linux/macOS). Optionally add a comment pointing Windows users to winget/scoop/MSI.

Verification

grep -n "winget install Initializ.Forge" docs/getting-started/installation.md README.md
grep -n "scoop install forge" docs/getting-started/installation.md

Done when: all three install paths are documented and the quick start reaches forge try in one step on Windows.


6. Global anti-patterns

  • Never change the MSI UpgradeCode after the first release.
  • Never ship unsigned artifacts.
  • Never blind-auto-launch forge try from the installer; opt-in checkbox + shortcut only.
  • Never commit certs, PATs, or SP secrets; GitHub Secrets / OIDC only.
  • Never rewrite the existing Homebrew/Docker/install.sh jobs; add alongside.
  • Never build the MSI off-Windows (UI/CA/signing/testing need Windows).
  • Never let PATH entries duplicate; the Environment element + MajorUpgrade own PATH lifecycle.
  • MSI ProductVersion is numeric x.y.z only — strip v and prerelease/build metadata.
  • winget-releaser: classic PAT (public_repo), fork under initializ, base version must pre-exist.
  • No em-dashes in user-facing installer strings and docs (house style).

7. Definition of done

winget install Initializ.Forge and scoop install forge both install a signed forge onto PATH. Every GitHub release carries a signed x64 MSI that installs forge to PATH and adds an opt-in "Forge — Get Started" Start Menu shortcut + finish-page checkbox launching forge try in a fresh shell. signtool verify passes on the binary and MSI. The existing Homebrew, Docker, and install.sh release paths are unchanged and green. Docs lead with the Windows-native commands.

8. Deferred (name, do not build)

  • MSIX / Microsoft Store package.
  • EV code-signing certificate for instant SmartScreen reputation.
  • Consolidating MSI generation into GoReleaser Pro (single run for scoop + winget + MSI).
  • Chocolatey package.
  • ARM64 Windows MSI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationenhancementNew feature or requestforge-cliAffects the forge-cli command-line tool (init, run, build, mcp commands)

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions