mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 21:47:13 +00:00
1dd4a477c5
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>
449 lines
22 KiB
YAML
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 }}
|