Skip to content

Building and Signing

Rod Christiansen edited this page Sep 5, 2026 · 2 revisions

Building and Signing

This page covers building BootstrapMate for Windows from source: what you need installed, every build.ps1 parameter, where the output lands, how the MSI is versioned and upgraded, and how code signing works. Read it before you cut a build you intend to deploy — the GitHub release workflow produces artifacts, but not signed ones.

Prerequisites

  • .NET SDK 10.0.x. Both GitHub workflows pin dotnet-version: '10.0.x'.
  • A Windows host. The command-line tool builds for win-x64 and win-arm64; both workflows use windows-latest.
  • Windows SDK, for signtool.exe and makepri.exe. Only needed if you sign, or if you want a resources.pri for the GUI.
  • PowerShell, to run build.ps1.
  • Nothing else needs pre-installing. NuGet restores from nuget.org only (nuget.config clears all other sources), the WiX toolset comes in as the WixToolset.Sdk/6.0.2 MSBuild SDK, and build.ps1 downloads IntuneWinAppUtil.exe if it cannot find one.

Which target framework actually binds

The tree contains two answers and only one of them takes effect.

File TargetFramework Binds?
Directory.Build.props net8.0-windows No
BootstrapMate.csproj (CLI) net10.0-windows Yes
src/BootstrapMate.Core/BootstrapMate.Core.csproj net10.0-windows Yes
src/BootstrapMate.App/BootstrapMate.App.csproj net10.0-windows10.0.19041.0 Yes

A property set in a project file overrides the same property inherited from Directory.Build.props, and all three project files set TargetFramework explicitly. The same is true of AssemblyVersion: Directory.Build.props declares 1.0.0.0, and BootstrapMate.csproj overwrites it with the build timestamp. Directory.Build.props also carries an AssemblyCompany of Your Organization, which nothing ships. Treat the file as inert and read the project files.

build.ps1 parameters

build.ps1 is the full pipeline: version stamp, publish per architecture, sign, build the MSI, sign that, and wrap each MSI in a .intunewin. Run with no parameters for the default build.

.\build.ps1
Parameter Type Default What it produces
-Thumbprint <string> string none Signs with exactly this certificate, looked up in CurrentUser\My then LocalMachine\My, skipping automatic discovery
-Architecture x64|arm64|both string both Restricts which runtime identifiers are published, and therefore which MSIs and .intunewin files are made
-Clean switch off Wipes publish\executables\<arch>, plus publish\msi and publish\intunewin, before building
-Test switch off After each architecture builds, runs the produced executable with --version and --help as a smoke test
-AllowUnsigned switch off Skips signing entirely. Development only
-SkipMSI switch off Builds the executables and stops — no MSI, no .intunewin
-ListCerts switch off Prints the candidate signing certificates and exits 0 without building
-FindCertSubject <string> string none Prints certificates whose subject contains this substring and exits 0 without building

The README documents a -Sign parameter. There is no such parameter — signing is attempted automatically unless you pass -AllowUnsigned.

Order of operations

  1. Load .env into the process environment (see Signing).
  2. Prune old build artifacts, keeping the newest MSI and .intunewin per architecture.
  3. Handle -ListCerts / -FindCertSubject and exit if either was given.
  4. Regenerate the application .ico from its PNG source.
  5. Compute the two version strings (see Versioning).
  6. Check that dotnet is on PATH, and that the WiX project exists unless -SkipMSI.
  7. Select a signing certificate.
  8. Publish and sign the command-line tool, per architecture.
  9. Publish the GUI and generate its resources.pri, per architecture.
  10. Build and sign the MSI, per architecture.
  11. Wrap each MSI as a .intunewin.

It exits 0 when everything succeeded and 1 otherwise.

Output layout

All output is under publish\ in the repository root.

Path Contents
publish\executables\<arch>\managedbootstrapinstall.exe The command-line tool, self-contained single file
publish\app\<arch>\BootstrapMate.exe The GUI, self-contained, with its WinUI 3 payload alongside
publish\msi\BootstrapMate-<arch>-<version>.msi The installer, copied out of installer\bin\<arch>\Release\
publish\intunewin\BootstrapMate-<arch>-<version>.intunewin The Intune Win32 package

<arch> is x64 or arm64; <version> is the full YYYY.MM.DD.HHMM string.

The publish command for the command-line tool is:

dotnet publish BootstrapMate.csproj --configuration Release --runtime win-x64 --output publish\executables\x64 --self-contained true --verbosity minimal

Building the MSI

build.ps1 builds the WiX project once per architecture:

dotnet build installer\BootstrapMate.Installer.wixproj --configuration Release -p:Platform=x64 -p:ProductVersion=26.9.3.605 -p:BinDir=<publish\executables\x64> -p:AppDir=<publish\app\x64>

The .wixproj fails the build with a readable message if managedbootstrapinstall.exe is missing from BinDir or BootstrapMate.exe is missing from AppDir, so build the executables first. It suppresses ICE03, because two of the WinUI 3 MUI locales have LCIDs outside the ICE table and Microsoft.ui.xaml.dll overflows the 255-character File.Language column.

If ProductVersion is not supplied, Product.wxs defaults it to 0.0.0.

UpgradeCode, ProductCode and what that means for upgrades

Product.wxs declares a fixed UpgradeCode of {87654321-4321-8765-CBA9-BA0987654321}. It never changes, which is what makes every build recognisable to Windows Installer as the same product family.

There is no ProductCode in Product.wxs. WiX generates a new one for each build, which is the normal arrangement for a MajorUpgrade package: each new MSI is a distinct product that removes the previous one during install.

The MajorUpgrade element is configured as:

Attribute Value Consequence
AllowSameVersionUpgrades no Two MSIs built in the same minute carry the same version and will not upgrade one another
AllowDowngrades no Installing an older MSI over a newer one fails with A newer version of [ProductName] is already installed.
MigrateFeatures yes The previous install's feature selection is carried forward
Schedule afterInstallInitialize The old version is removed before the new files are laid down

The GUI payload is generated into a GuiAppFiles ComponentGroup by installer/Generate-GuiAppFiles.ps1. It derives Component and File ids deterministically, as c_ or f_ plus the first 16 hexadecimal characters of the SHA1 of the lowercased relative path, so that an unchanged file keeps the same identity across builds and upgrade tracking does not break when the Windows App SDK ships a new locale file.

Because AllowSameVersionUpgrades="no" and the version is minute-resolution, the practical rule is: one deployable MSI per minute per architecture.

Code signing

Signing runs automatically. It is not gated behind a parameter — the only way to turn it off is -AllowUnsigned.

Certificate discovery

build.ps1 looks for a certificate in this order and stops at the first hit:

  1. The -Thumbprint parameter, or the CERT_THUMBPRINT environment variable, searched in CurrentUser\My then LocalMachine\My.
  2. A certificate whose Common Name matches the BOOTSTRAPMATE_CERT_CN environment variable.
  3. A certificate whose subject contains the BOOTSTRAPMATE_CERT_SUBJECT substring.
  4. A LocalMachine certificate with the Code Signing EKU whose subject does not contain Test.
  5. A certificate with no EKU whose subject contains one of a small set of keywords, LocalMachine before CurrentUser.
  6. Any certificate with the Code Signing EKU whose subject does not contain Test.
  7. As a last resort, any certificate at all.

BOOTSTRAPMATE_CERT_CN and BOOTSTRAPMATE_CERT_SUBJECT each fall back to two older names: ENTERPRISE_CERT_CN / CIMIAN_CERT_CN and ENTERPRISE_CERT_SUBJECT / CIMIAN_CERT_SUBJECT.

Those variables come from a .env file in the repository root, which build.ps1 reads into the process environment before anything else. .env.example and .env.template show the shape. .env is build-time only — no shipped binary reads it, and it is not part of any artifact.

Use -ListCerts to see what the discovery step can see on your machine, and -FindCertSubject to check a specific subject substring, before you commit to a build.

If no certificate is found, the build does not fail. It logs No code signing certificate found — building unsigned and continues. Check the log, not the exit code, when you care whether the output is signed.

How artifacts are signed

build.ps1 resolves signtool.exe from PATH first, preferring a \x64\ path, then from Windows Kits\10\bin under both Program Files roots and from the KitsRoot10 registry value, preferring an x64 build, then arm64, then x86, newest SDK first.

The command it issues has this shape, with /sm added only when the certificate came from the LocalMachine store:

signtool sign /s My /sha1 <thumbprint> /fd SHA256 /td SHA256 /tr <timestamp-url> /v <file>

Timestamping is not optional and not single-sourced. Signing is attempted up to four times, cycling through four RFC 3161 timestamp authorities:

  • http://timestamp.digicert.com
  • http://timestamp.sectigo.com
  • http://timestamp.entrust.net/TSS/RFC3161sha2TS
  • http://timestamp.comodoca.com/authenticode

An optional legacy signtool timestamp pass may follow. After signing, build.ps1 polls for up to 60 seconds for the file to become accessible again, because endpoint protection commonly holds a freshly signed file open. On ARM64 hosts it additionally adds a temporary Defender exclusion and runs takeown on the artifact before signing, removing the exclusion afterwards.

Both the executables and the MSIs are signed. The .intunewin wrapper is built from the signed MSI.

Unsigned development build

.\build.ps1 -AllowUnsigned -Architecture x64 -SkipMSI

That gives you publish\executables\x64\managedbootstrapinstall.exe with no MSI and no signing attempt. Drop -SkipMSI if you also want an unsigned MSI to test the install sequence. Add -Test to have the script run --version and --help against the result.

Unsigned builds are for development. BootstrapMate's own Authenticode gate applies to the packages it installs, not to itself, but MDM deployment of an unsigned MSI is a separate problem — see Security and Package Verification.

Versioning: binary versus MSI

There are two version strings and they are not the same shape, because MSI's ProductVersion requires its first three components to be below 65536 and a four-digit year does not fit.

Binary version — YYYY.MM.DD.HHMM

Set at compile time, not by build.ps1. BootstrapMate.csproj computes $([System.DateTime]::Now.ToString("yyyy.MM.dd.HHmm")) and uses it for AssemblyVersion, FileVersion, an AssemblyMetadataAttribute("BuildTimestamp", …) and a BUILD_VERSION compile constant. The tool reads that attribute back at runtime, falling back to dev.build if it is absent.

Worked example. A CLI publish that compiles at 06:05 on 3 September 2026 produces:

2026.09.03.0605

That is what managedbootstrapinstall.exe --version prints, what appears on the Version: line of the session log, and what is written to LastRunVersion after a run in which every package installed.

build.ps1 computes the same format independently for naming artifacts, and also tries to rewrite a private static readonly string Version = "…"; literal in Program.cs. That literal no longer exists, so it logs C# code uses dynamic version generation (as designed) and moves on. Because the binary stamps itself at compile time while build.ps1 stamps the filename earlier in the run, the two can differ by a minute or two on a slow build.

MSI version — YY.M.D.HHMM

build.ps1 derives the MSI ProductVersion by subtracting 2000 from the year and dropping the zero padding from the month, day and time components:

26.9.3.605

The release workflow derives its MSI version from the tag instead, subtracting 2000 from the first component and keeping the remaining components as they appear in the tag, so a tag of v2026.09.03.0605 yields 26.9.3.0605.

The full YYYY.MM.DD.HHMM string is what appears in the artifact filenames (BootstrapMate-x64-2026.09.03.0605.msi) and in the git tag. The YY.M.D.HHMM string is what Windows Installer records internally and what the MSI writes to HKLM\SOFTWARE\BootstrapMateVersion.

What the release workflow produces, and what it does not

.github/workflows/release.yml runs on a push of a v* tag and, for each of x64 and arm64, publishes the command-line tool, publishes the GUI, generates resources.pri, builds the MSI, and stages two files:

  • BootstrapMate-<arch>-<version>.msi
  • BootstrapMate-<arch>-<version>.zip, containing the command-line executable and the entire GUI publish tree

A second job downloads both architectures' artifacts and creates the GitHub Release.

These artifacts are unsigned. The workflow has no certificate and calls no signtool. The generated release notes say so explicitly: It is provided unsigned — see signing instructions at the bottom. A green release build means the code compiled and packaged, not that a deployable artifact exists. Either build locally with build.ps1 so signing happens, or download the release MSI and sign it yourself before you distribute it.

.github/workflows/ci.yml, which runs on pushes and pull requests to main, is narrower still: it publishes both projects and builds the MSI for both architectures, uploads nothing, and runs no tests. It is a compile gate.

See also

Clone this wiki locally