Files
Oliver Kieselbach 1dd4a477c5 Bootstrap gates run on every change and before every signing or upload (D-385)
The gates were attached to publishing: they ran only as the first job of the
script publish. Holding that publish back with GitHub's skip-actions marker
dropped them together with CI, CodeQL and zizmor. The stable agent release
published the same scripts without them, the MSI build signed the loader
without them, and no gate page ever showed a run.

- pester.yml, shown as "Bootstrap script gates", runs on pushes to main as
  well as pull requests, with one list of every input of the checks.
- publish-scripts.yml: a push whose commit message carries the skip-publish
  marker (spelled out in the workflow) runs neither job and uploads nothing;
  every check of the push still runs.
- build-agent.yml: a publish_as_stable release waits for the gates, which run
  next to the build and are skipped otherwise.
- build-msi.yml: nothing is signed before the gates pass. The loader's own
  parse/ASCII step goes, since the gates cover it.
- The reusable workflow is named "Bootstrap script gates (reusable)", since
  its runs show under the caller only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:29:19 +02:00

449 lines
22 KiB
YAML

name: Build & Publish Bootstrap MSI
# Thin MDM LOB delivery vehicle for WDP (Autopilot v2) — see
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI is packaging only: it
# carries the SAME stage-1 loader that is assigned directly in Intune, so it is rebuilt
# rarely — only when that loader or the WiX package changes. Version lives in Package.wxs
# — bump it there before dispatching so Intune LOB update detection sees a new
# ProductVersion.
on:
workflow_dispatch:
inputs:
upload_blob:
description: 'Upload to blob (customer-facing through the download alias)'
required: true
type: boolean
default: true
# Every job states its own permissions. This default is what the build job runs with: wix is
# third-party code and must never hold more than a read-only token.
permissions:
contents: read
env:
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify steps.
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
jobs:
# The loader executes as SYSTEM on every enrolling device, and the MSI's custom action runs
# it: nothing is signed before the bootstrap gates pass on this commit, since a signed copy
# of ours is trusted wherever it lands.
gates:
uses: $/.github/workflows/bootstrap-script-gates.yml
# Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed
# afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy
# need the embedded loader to carry a signature of its own.
#
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run
# here. The `release` environment is the only subject the code-signing identity trusts.
sign-loader:
name: Sign bootstrap loader
needs: gates
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing login
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Every host in this workflow comes from the one endpoint registry.
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# Every published MSI is signed; a missing variable stops the run before the build.
- name: Check code-signing configuration
shell: pwsh
run: |
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed."
}
}
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# A missing timestamp fails the run: without it the signature dies with the certificate.
- name: Sign bootstrap loader (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description 'Autopilot Monitor bootstrap runner' `
--description-url $env:WWW_BASE_URL `
$env:RUNNER_SCRIPT
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
$sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT
if ($sig.Status -ne 'Valid') { throw "Signature on $env:RUNNER_SCRIPT is '$($sig.Status)', expected 'Valid'." }
if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:RUNNER_SCRIPT carries no timestamp." }
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "$env:RUNNER_SCRIPT was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
- name: Upload signed loader
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bootstrap-loader-signed
path: ${{ env.RUNNER_SCRIPT }}
if-no-files-found: error
retention-days: 1
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
# release job treats that as data and never builds.
build:
name: Build MSI
needs: sign-loader
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download signed loader
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bootstrap-loader-signed
path: ${{ runner.temp }}/loader
# The signed copy replaces the checkout's unsigned one before wix embeds it.
- name: Put the signed loader in place
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Copy-Item -Path (Join-Path $env:RUNNER_TEMP 'loader/Start-AutopilotMonitor.ps1') -Destination $env:RUNNER_SCRIPT -Force
$sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT
if ($sig.Status -ne 'Valid') { throw "Loader to embed is '$($sig.Status)', expected 'Valid'." }
Write-Host "Embedding the signed loader ($((Get-Item $env:RUNNER_SCRIPT).Length) bytes)"
# Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers
# install, so a compromised or simply broken point release must not flow into a build
# unnoticed. Bumping is a reviewed commit.
- name: Install WiX toolset
run: dotnet tool install --global wix --version 5.0.2
- name: Build MSI
shell: pwsh
run: |
# The MSI's action runs only the exact bytes the package embeds: the hash of the
# signed loader goes into the command here, so no other signed script of ours runs
# in its place on a device.
$loaderSha256 = (Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Embedded loader SHA-256: $loaderSha256"
# wix resolves File/@Source relative to the working directory, not the wxs —
# build from inside the project dir (same as the local build documented in the wxs).
$out = Join-Path (Get-Location) $env:STABLE_MSI
Push-Location $env:MSI_PROJECT_DIR
wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256 -o $out
Pop-Location
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
- name: Upload unsigned MSI
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bootstrap-msi-unsigned
path: ${{ env.STABLE_MSI }}
if-no-files-found: error
retention-days: 1
# The real chain on a runner, before anything is signed or published: msiexec installs the
# MSI from the build, its action checks the loader copy and runs it, the loader fetches the
# published installer, which finds the deployment marker set here and removes the copy. The
# marker is the installer's first guard, so no agent is ever installed on the runner. Needs
# the published installer to be 2.9 or later, which removes the copy in every outcome.
smoke:
name: Install on a runner (${{ matrix.os }})
needs: build
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download unsigned MSI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bootstrap-msi-unsigned
path: ${{ runner.temp }}/msi
- name: Install with the deployment marker set, check the footprint, uninstall
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI"
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') { throw 'Could not parse Version from Package.wxs' }
$version = $Matches[1]
New-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -Value ((Get-Date).ToUniversalTime().ToString('o'))
function Invoke-Msiexec {
param([string]$Verb, [string]$Log)
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/$Verb `"$msi`" /qn /l*v `"$Log`"" -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content $Log | Select-Object -Last 60; throw "msiexec /$Verb exited with $($p.ExitCode)" }
}
function Get-Registration {
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Autopilot Monitor Bootstrap' }
}
function Show-Tree {
param([string]$Path)
Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { Write-Host " $($_.FullName)" }
}
$installLog = Join-Path $env:RUNNER_TEMP 'msi-install.log'
Invoke-Msiexec -Verb i -Log $installLog
$registered = Get-Registration
if (-not $registered) { throw 'The product is not registered after the install.' }
if ($registered.DisplayVersion -ne $version) { throw "Registered version $($registered.DisplayVersion), Package.wxs says $version." }
if ((Get-Content $installLog -Raw) -notmatch 'RunBootstrap') { throw 'The install log does not show the action.' }
Write-Host "Registered: $($registered.DisplayName) $($registered.DisplayVersion)"
# Only the installer removes the copy the MSI put under ProgramData: its absence
# proves the action ran the loader and the loader ran the installer.
foreach ($path in @("$env:ProgramW6432\AutopilotMonitor", "$env:ProgramData\AutopilotMonitor")) {
if (Test-Path -LiteralPath $path) { Show-Tree $path; throw "$path exists after the run." }
}
$runFolders = @(Get-ChildItem -LiteralPath $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -Force)
if ($runFolders.Count -ne 0) {
foreach ($folder in $runFolders) {
Show-Tree $folder.FullName
Get-ChildItem -LiteralPath $folder.FullName -Filter '*.log' | ForEach-Object { Get-Content -LiteralPath $_.FullName }
}
throw 'A loader run folder stays: the loader did not end with exit 0.'
}
Write-Host 'After the install: nothing under ProgramData or Program Files.'
Invoke-Msiexec -Verb x -Log (Join-Path $env:RUNNER_TEMP 'msi-uninstall.log')
if (Get-Registration) { throw 'The product is still registered after the uninstall.' }
Remove-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Recurse -Force
Write-Host 'Uninstalled; the registration is gone.'
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
# `release`, like sign-loader.
release:
name: Sign and publish MSI
needs: smoke
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing and storage logins, keyless Sigstore signing
attestations: write # store the provenance attestation
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# Read from this job's own checkout, never handed over by the build job.
- name: Read MSI version from Package.wxs
id: version
shell: pwsh
run: |
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') {
Write-Error "Could not parse Version from Package.wxs"
exit 1
}
$version = $Matches[1]
Write-Host "MSI version (from Package.wxs): $version"
"version=$version" >> $env:GITHUB_OUTPUT
- name: Download unsigned MSI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bootstrap-msi-unsigned
path: ${{ runner.temp }}/msi
# Only the expected file leaves the download folder.
- name: Take the MSI from the build
shell: pwsh
run: Copy-Item -Path (Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI") -Destination $env:STABLE_MSI -ErrorAction Stop
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
# account, so the storage login below has to be the last one.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# The blob upload below is authorized by this federated identity, not by a SAS.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# Signing rewrites the MSI, so it runs before the hash and the upload. A missing
# timestamp fails the run: without it the signature dies with the certificate.
- name: Sign MSI (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description 'Autopilot Monitor Bootstrap' `
--description-url $env:WWW_BASE_URL `
$env:STABLE_MSI
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
# The loader was verified in sign-loader; here the MSI, before anything is published.
$sig = Get-AuthenticodeSignature -FilePath $env:STABLE_MSI
if ($sig.Status -ne 'Valid') { throw "Signature on $env:STABLE_MSI is '$($sig.Status)', expected 'Valid'." }
if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:STABLE_MSI carries no timestamp." }
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "$env:STABLE_MSI was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
Write-Host "$env:STABLE_MSI : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
Write-Host "SHA-256: $((Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant())"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
env:
MSI_VERSION: ${{ steps.version.outputs.version }}
run: |
$containerUrl = $env:AGENT_CONTAINER_URL
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$version = $env:MSI_VERSION
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
# stable name rotates in place behind the download alias. No legacy mirror — the
# MSI channel never existed on the legacy account.
function Send-Blob {
param([string]$Local, [string]$BlobName)
$bytes = [System.IO.File]::ReadAllBytes($Local)
$headers = @{
"x-ms-blob-type" = "BlockBlob"
"Content-Type" = "application/octet-stream"
"x-ms-blob-cache-control" = "no-cache"
}
$md5 = [System.Security.Cryptography.MD5]::Create()
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
$md5.Dispose()
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1KB)) KB)..."
Invoke-RestMethod -Uri "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $headers) -Body $bytes
Write-Host " uploaded $BlobName"
}
# Versioned copy for provenance/rollback + stable customer-facing name.
Send-Blob -Local $env:STABLE_MSI -BlobName "AutopilotMonitor-Bootstrap-v$version.msi"
Send-Blob -Local $env:STABLE_MSI -BlobName $env:STABLE_MSI
# E2E: the Front Door route /agent/* must serve the fresh bytes on the public alias.
- name: Verify download alias serves the MSI
if: inputs.upload_blob
shell: pwsh
run: |
$localSize = (Get-Item $env:STABLE_MSI).Length
$resp = Invoke-WebRequest -Uri "$env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI" -Method Head -UseBasicParsing
if ($resp.StatusCode -ne 200) { Write-Error "Alias HEAD returned $($resp.StatusCode)"; exit 1 }
$remoteSize = [long]$resp.Headers['Content-Length'][0]
if ($remoteSize -ne $localSize) {
Write-Error "Size mismatch: alias serves $remoteSize bytes, local MSI is $localSize bytes"
exit 1
}
Write-Host "Alias verified: $env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI ($remoteSize bytes)"
# Sigstore keyless provenance, same as the agent ZIP; covers the signed bytes. Skipped
# on runs that publish nothing -- no log entry for bytes nobody receives. Verify:
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (MSI)
if: inputs.upload_blob
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: ${{ env.STABLE_MSI }}
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
path: ${{ env.STABLE_MSI }}