mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
829ec4c969
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>
362 lines
19 KiB
YAML
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
|