-
Notifications
You must be signed in to change notification settings - Fork 1
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.
- .NET SDK 10.0.x. Both GitHub workflows pin
dotnet-version: '10.0.x'. - A Windows host. The command-line tool builds for
win-x64andwin-arm64; both workflows usewindows-latest. - Windows SDK, for
signtool.exeandmakepri.exe. Only needed if you sign, or if you want aresources.prifor the GUI. - PowerShell, to run
build.ps1. - Nothing else needs pre-installing. NuGet restores from nuget.org only (
nuget.configclears all other sources), the WiX toolset comes in as theWixToolset.Sdk/6.0.2MSBuild SDK, andbuild.ps1downloadsIntuneWinAppUtil.exeif it cannot find one.
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 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.
- Load
.envinto the process environment (see Signing). - Prune old build artifacts, keeping the newest MSI and
.intunewinper architecture. - Handle
-ListCerts/-FindCertSubjectand exit if either was given. - Regenerate the application
.icofrom its PNG source. - Compute the two version strings (see Versioning).
- Check that
dotnetis on PATH, and that the WiX project exists unless-SkipMSI. - Select a signing certificate.
- Publish and sign the command-line tool, per architecture.
- Publish the GUI and generate its
resources.pri, per architecture. - Build and sign the MSI, per architecture.
- Wrap each MSI as a
.intunewin.
It exits 0 when everything succeeded and 1 otherwise.
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 minimalbuild.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.
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.
Signing runs automatically. It is not gated behind a parameter — the only way to turn it off is
-AllowUnsigned.
build.ps1 looks for a certificate in this order and stops at the first hit:
- The
-Thumbprintparameter, or theCERT_THUMBPRINTenvironment variable, searched inCurrentUser\MythenLocalMachine\My. - A certificate whose Common Name matches the
BOOTSTRAPMATE_CERT_CNenvironment variable. - A certificate whose subject contains the
BOOTSTRAPMATE_CERT_SUBJECTsubstring. - A
LocalMachinecertificate with the Code Signing EKU whose subject does not containTest. - A certificate with no EKU whose subject contains one of a small set of keywords,
LocalMachinebeforeCurrentUser. - Any certificate with the Code Signing EKU whose subject does not contain
Test. - 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.
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.comhttp://timestamp.sectigo.comhttp://timestamp.entrust.net/TSS/RFC3161sha2TShttp://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.
.\build.ps1 -AllowUnsigned -Architecture x64 -SkipMSIThat 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.
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.
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 successful run.
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.
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\BootstrapMate → Version.
.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.