Files
AutopilotMonitor/.github/workflows/build-agent.yml
T
Oliver Kieselbach 829ec4c969 fix(release): stamp Cache-Control no-cache on every blob upload
The first release after the download-alias migration exposed Front Door
caching as a correctness hazard: the alias kept serving the previous
version.json (X-Cache: TCP_REMOTE_HIT) after the origin had rotated to
2.0.1370, pinning the portal's version badge to the old build. The dangerous
variant is worse than a stale badge -- manifest and ZIP are independent cache
entries, so a stale manifest paired with a fresh ZIP fails the bootstrap's
SHA-256 integrity check and the install dies.

Route caching on /agent/* has been disabled (infra side); this commit is the
defense-in-depth half: every upload now carries x-ms-blob-cache-control
no-cache -- the CI Send-Blob (eu and legacy mirror alike), the local build.ps1
counterpart (gitignored, changed in lockstep), and the versions/*.json
manifest publishes. Uniform no-cache rather than an immutable/mutable split
because every blob in the agent container rotates in place, including the
per-line AutopilotMonitor-Agent-v2.zip -- nothing there is safely cacheable.
Existing blobs get the header with the next release's re-upload.

Log/docs updated in url-registry.md; no code paths changed, YAML and ps1
parse-checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:17:02 +02:00

362 lines
19 KiB
YAML

name: Build & Release Agent (V2)
on:
workflow_dispatch:
inputs:
build_number:
description: 'Build number override (leave empty to auto-increment from buildcounter.txt)'
required: false
type: string
upload_blob:
description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
required: true
type: boolean
default: true
publish_as_stable:
description: 'CUTOVER: also push to stable namespace (Agent.zip + version.json) — customers immediately receive this build'
required: true
type: boolean
default: false
create_release:
description: 'Create a GitHub Release'
required: true
type: boolean
default: true
prerelease:
description: 'Mark release as pre-release'
required: false
type: boolean
default: false
permissions:
contents: write
id-token: write # keyless Sigstore signing (artifact attestation)
attestations: write # store the provenance attestation
env:
LINE_MAJOR: 2
AGENT_PROJECT: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
DIALOG_PROJECT: src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj
TEST_PROJECT: src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj
CSPROJ_PATH: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
BOOTSTRAP_SOURCE: scripts/Bootstrap/Install-AutopilotMonitor.ps1
AGENT_OUTPUT: src/Agent/AutopilotMonitor.Agent.V2/bin/Release/net48
DIALOG_OUTPUT: src/Agent/AutopilotMonitor.SummaryDialog/bin/Release/net48
MANIFEST_NAME: version-v2.json
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
STABLE_ZIP: AutopilotMonitor-Agent.zip
STABLE_MANIFEST: version.json
STABLE_BOOTSTRAP: Install-AutopilotMonitor.ps1
STABLE_BOOTSTRAP_DEV: Install-AutopilotMonitor-Dev.ps1
jobs:
build:
runs-on: windows-latest
name: Build Agent
steps:
- name: Checkout repository
uses: actions/checkout@v7
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
# Build numbers come from the shared counter blob -- one counter and one
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
# number; a failed build burns a number, which is harmless.
- name: Reserve build number (counter blob CAS)
id: version
shell: pwsh
run: |
$override = 0
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
$csproj = Get-Content $env:CSPROJ_PATH -Raw
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
$versionPrefix = $Matches[1]
} else {
$versionPrefix = "$env:LINE_MAJOR.0"
}
$version = "$versionPrefix.$buildNumber"
Write-Host "Full version: $version"
"build_number=$buildNumber" >> $env:GITHUB_OUTPUT
"version=$version" >> $env:GITHUB_OUTPUT
"tag=v$version" >> $env:GITHUB_OUTPUT
- name: Restore dependencies
run: |
dotnet restore ${{ env.AGENT_PROJECT }}
dotnet restore ${{ env.DIALOG_PROJECT }}
dotnet restore ${{ env.TEST_PROJECT }}
- name: Build Agent
run: dotnet build ${{ env.AGENT_PROJECT }} --configuration Release --no-restore
env:
BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
- name: Build SummaryDialog
run: dotnet build ${{ env.DIALOG_PROJECT }} --configuration Release --no-restore
- name: Run Agent unit tests
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
- name: Create release artifact
id: artifact
shell: pwsh
run: |
$version = "${{ steps.version.outputs.version }}"
$stagingDir = "staging/agent"
if (-not (Test-Path "$env:AGENT_OUTPUT/AutopilotMonitor.Agent.exe")) {
Write-Error "Agent build output not found at $env:AGENT_OUTPUT"
exit 1
}
if (-not (Test-Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe")) {
Write-Error "SummaryDialog build output not found at $env:DIALOG_OUTPUT"
exit 1
}
# The .exe.config is functionally REQUIRED: it carries the WPF AppContextSwitchOverrides
# that allow rendering when spawned from SYSTEM during enrollment (else 0x8007045A).
if (-not (Test-Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe.config")) {
Write-Error "SummaryDialog.exe.config not found at $env:DIALOG_OUTPUT -- ZIP would be functionally broken"
exit 1
}
New-Item -Path $stagingDir -ItemType Directory -Force | Out-Null
Copy-Item -Path "$env:AGENT_OUTPUT/*" -Destination $stagingDir -Recurse -Force
Copy-Item -Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe" -Destination $stagingDir -Force
Copy-Item -Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe.config" -Destination $stagingDir -Force
# Verify EXE version matches the MSBuild-generated manifest AND the reserved build
# number (parity with build.ps1 -- catches MSBuild property timing bugs).
$manifestPath = "$env:AGENT_OUTPUT/$env:MANIFEST_NAME"
if (-not (Test-Path $manifestPath)) {
Write-Error "$env:MANIFEST_NAME not found at $manifestPath -- MSBuild GenerateVersionJson target may have failed"
exit 1
}
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
$exeVersion = ([System.Diagnostics.FileVersionInfo]::GetVersionInfo("$stagingDir/AutopilotMonitor.Agent.exe").ProductVersion -replace '\+.*$', '')
if ($exeVersion -ne $manifest.version) {
Write-Error "VERSION MISMATCH: EXE='$exeVersion' but $env:MANIFEST_NAME='$($manifest.version)'"
exit 1
}
if ($manifest.version -ne $version) {
Write-Error "VERSION MISMATCH: $env:MANIFEST_NAME='$($manifest.version)' but reserved version='$version'"
exit 1
}
Write-Host "Version check passed: EXE = manifest = reserved = $version"
$versionedZip = "AutopilotMonitor-Agent-v$version.zip"
Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip
Write-Host "Created GitHub-Release artifact: $versionedZip"
# Same bytes, blob-namespace name (versioned)
Copy-Item $versionedZip $env:VERSIONED_ZIP
Write-Host "Created blob deployment artifact: $env:VERSIONED_ZIP"
$sha256Hash = (Get-FileHash $env:VERSIONED_ZIP -Algorithm SHA256).Hash.ToLowerInvariant()
$exeSha256Hash = (Get-FileHash "$stagingDir/AutopilotMonitor.Agent.exe" -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "ZIP SHA-256: $sha256Hash"
Write-Host "EXE SHA-256: $exeSha256Hash"
$bootstrapVersion = $null
$bootstrapContent = Get-Content $env:BOOTSTRAP_SOURCE -Raw
if ($bootstrapContent -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') {
$bootstrapVersion = $Matches[1]
Write-Host "Bootstrap script version: $bootstrapVersion"
} else {
Write-Warning "Could not parse bootstrap `$ScriptVersion from $env:BOOTSTRAP_SOURCE"
}
# Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above).
# commit/buildUtc are the same two fields the backend and MCP manifests carry —
# additive, and the bootstrap reads this file by field name, so older installers
# ignore them. sha256/bootstrapVersion stay: those are the customer contract.
$manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
$manifest | Add-Member -NotePropertyName "commit" -NotePropertyValue '${{ github.sha }}'.Substring(0, 7) -Force
$manifest | Add-Member -NotePropertyName "buildUtc" -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force
if ($bootstrapVersion) {
$manifest | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force
}
$manifest | ConvertTo-Json -Compress | Set-Content $env:MANIFEST_NAME -Encoding UTF8
Write-Host "$env:MANIFEST_NAME content: $(Get-Content $env:MANIFEST_NAME)"
# Bootstrap = single generic source. Stable variant = source 1:1.
# Dev variant = literal-string substitution of the two URL/manifest defaults.
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
$stableMan = '$VersionJsonName = "version.json"'
$devMan = '$VersionJsonName = "version-dev.json"'
Copy-Item $env:BOOTSTRAP_SOURCE $env:STABLE_BOOTSTRAP -Force
$content = Get-Content $env:BOOTSTRAP_SOURCE -Raw
if ($content.IndexOf($stableUrl) -lt 0 -or $content.IndexOf($stableMan) -lt 0) {
Write-Error "Bootstrap dev-render: anchor literal missing in $env:BOOTSTRAP_SOURCE"
exit 1
}
$devContent = $content.Replace($stableUrl, $devUrl).Replace($stableMan, $devMan)
[System.IO.File]::WriteAllText("$env:STABLE_BOOTSTRAP_DEV", $devContent, (New-Object System.Text.UTF8Encoding $false))
}
"versioned_zip_release=$versionedZip" >> $env:GITHUB_OUTPUT
"sha256=$sha256Hash" >> $env:GITHUB_OUTPUT
"exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT
"bootstrap_version=$bootstrapVersion" >> $env:GITHUB_OUTPUT
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
run: |
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
# Legacy mirror (transition until customers moved to the download.-alias):
# blobs are ALSO pushed to the old account so the bootstrap URL baked into
# customers' Intune scripts keeps serving current bits. Fail-soft -- a
# legacy hiccup must never fail a release. Counter lives ONLY on eu.
$legacyContainerUrl = "https://autopilotmonitor.blob.core.windows.net/agent"
$legacySasToken = "${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}".TrimStart('?')
function Send-Blob {
param([string]$Local, [string]$BlobName, [string]$ContentType)
$url = "$containerUrl/$BlobName`?$sasToken"
$bytes = [System.IO.File]::ReadAllBytes($Local)
# no-cache on EVERY blob: they all rotate in place (even the per-line ZIP is
# overwritten each build), and the download alias fronts them with Front Door.
# Route caching is disabled there, but a stale manifest paired with a fresh ZIP
# (independent cache entries) would fail the bootstrap SHA check -- this header
# keeps that class of bug impossible even if caching is ever re-enabled.
$headers = @{ "x-ms-blob-type" = "BlockBlob"; "Content-Type" = $ContentType; "x-ms-blob-cache-control" = "no-cache" }
if ($ContentType -eq "application/zip") {
$md5 = [System.Security.Cryptography.MD5]::Create()
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
$md5.Dispose()
}
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1MB, 2)) MB)..."
Invoke-RestMethod -Uri $url -Method Put -Headers $headers -Body $bytes
Write-Host " uploaded $BlobName"
if (-not [string]::IsNullOrEmpty($legacySasToken)) {
try {
Invoke-RestMethod -Uri "$legacyContainerUrl/$BlobName`?$legacySasToken" -Method Put -Headers $headers -Body $bytes
Write-Host " mirrored $BlobName to legacy storage"
} catch {
Write-Host "::warning::legacy mirror failed for $BlobName -- $($_.Exception.Message)"
}
}
}
# Versioned namespace (always)
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json"
# Stable namespace (only on cutover)
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
Write-Host "PUBLISH AS STABLE -- writing V2 into stable namespace (customer-facing)" -ForegroundColor Magenta
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
Send-Blob -Local $env:STABLE_BOOTSTRAP -BlobName $env:STABLE_BOOTSTRAP -ContentType "text/plain; charset=utf-8"
Send-Blob -Local $env:STABLE_BOOTSTRAP_DEV -BlobName $env:STABLE_BOOTSTRAP_DEV -ContentType "text/plain; charset=utf-8"
if (Test-Path "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1") {
Send-Blob -Local "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1" -BlobName "Test-ShouldBootstrapAgent.ps1" -ContentType "text/plain; charset=utf-8"
}
}
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
# must never point at a build that is not actually in the stable namespace.
$tableSasToken = "${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}".TrimStart('?')
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
} elseif ([string]::IsNullOrEmpty($tableSasToken) -or $tableSasToken -eq "XXX") {
Write-Host "SKIPPED: Table SAS token not configured -- set AZURE_TABLE_ADMIN_SAS_TOKEN secret to enable backend hash-oracle"
} else {
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
$agentVersion = "${{ steps.version.outputs.version }}"
$bootstrapVersion = "${{ steps.artifact.outputs.bootstrap_version }}"
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken"
$tableBody = @{
LatestAgentV2Version = $agentVersion
LatestAgentV2Sha256 = $sha256
LatestAgentV2ExeSha256 = $exeSha256
}
if ($bootstrapVersion) {
$tableBody["LatestBootstrapV2ScriptVersion"] = $bootstrapVersion
}
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{
"Content-Type" = "application/json"
"Accept" = "application/json;odata=nometadata"
"If-Match" = "*"
} -Body ($tableBody | ConvertTo-Json)
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256, bootstrapVersion=$bootstrapVersion"
}
# Sigstore keyless provenance: binds the ZIP digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log. The digest covers the identical
# bytes uploaded to blob (versioned AND stable), so one attestation covers all copies.
# Verify: gh attestation verify <zip> --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (ZIP)
uses: actions/attest-build-provenance@v4
with:
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload version metadata
uses: actions/upload-artifact@v7
with:
name: version-metadata
path: ${{ env.MANIFEST_NAME }}
- name: Create GitHub Release
if: inputs.create_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
body: |
## Autopilot Monitor Agent ${{ steps.version.outputs.version }}
### Installation
1. Download `AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip`
2. Extract to `C:\ProgramData\AutopilotMonitor\Agent\`
3. Run `AutopilotMonitor.Agent.exe --install` to register the Scheduled Task
### Included Components
- **AutopilotMonitor.Agent.exe** - Monitoring agent (V2 release line)
- **AutopilotMonitor.SummaryDialog.exe** - Enrollment summary dialog (WPF)
- **version-v2.json** - Per-line version manifest
### Version Info
- **Version:** ${{ steps.version.outputs.version }}
- **Build Number:** ${{ steps.version.outputs.build_number }}
- **SHA-256:** `${{ steps.artifact.outputs.sha256 }}`
- **Built from:** ${{ github.sha }}
- **Published as stable:** ${{ inputs.publish_as_stable }}
### Provenance
This ZIP carries a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
```
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```
draft: false
prerelease: ${{ inputs.prerelease }}
files: |
${{ steps.artifact.outputs.versioned_zip_release }}
${{ env.MANIFEST_NAME }}
generate_release_notes: true