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 channel: description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false' required: true type: choice options: - release - dev default: release 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 release_notes: description: 'Customer-facing notes for the GitHub Release (Markdown bullets). Empty = generic fallback linking to the docs changelog' required: false type: string # Every job states its own permissions. This default is what the build job runs with: it # executes third-party code (MSBuild targets, analyzers, test frameworks) and must never hold # more than a read-only token. permissions: contents: read # Shared with publish-scripts.yml: both write version.json (this workflow the agent fields, # the script publisher bootstrapVersion under If-Match). Serialising them keeps a script push # from colliding with a release. Never cancel -- a half-published set is worse than a queue. concurrency: group: agent-blob-publish cancel-in-progress: false env: LINE_MAJOR: 2 AGENT_PROJECT: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj DIALOG_PROJECT: src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj DECISIONCORE_TEST_PROJECT: src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj CORE_TEST_PROJECT: src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj DIALOG_TEST_PROJECT: src/Agent/AutopilotMonitor.SummaryDialog.Tests/AutopilotMonitor.SummaryDialog.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 # Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read. CHANNEL: ${{ inputs.channel }} DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip DEV_MANIFEST: version-v2-dev.json DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip DEV_STABLE_MANIFEST: version-dev.json # Signed catalog over every other file of the payload. Must equal # Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step. PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat # 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 step. 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: # Runs only this repository's scripts. The deploy identity trusts the `production` # environment, which admits main only. reserve: name: Reserve build number runs-on: ubuntu-latest environment: production permissions: contents: read id-token: write # OIDC federated login for the counter write outputs: build_number: ${{ steps.version.outputs.build_number }} version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} 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 released build is signed; a missing variable stops the run before a build # number is burnt. - 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 released build must be Authenticode-signed." } } Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')" # The dev channel is a lab channel: it never touches stable, AdminConfiguration, the # bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt. - name: Check channel inputs shell: pwsh env: PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }} CREATE_RELEASE: ${{ inputs.create_release }} run: | if ($env:CHANNEL -notin @('release', 'dev')) { throw "Unknown channel '$($env:CHANNEL)'." } if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) { throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false." } Write-Host "Channel: $env:CHANNEL" - 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 }} # 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 # Input and secret reach the script only as environment variables, never as script # text: ${{ }} is substituted before pwsh parses the block, so a crafted # build_number could otherwise break out of the quotes and run arbitrary code. env: BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }} run: | $override = 0 if ($env:BUILD_NUMBER_OVERRIDE -ne '') { if ($env:BUILD_NUMBER_OVERRIDE -notmatch '^\d+$') { throw "build_number must be a positive integer, got '$($env:BUILD_NUMBER_OVERRIDE)'" } $override = [int]$env:BUILD_NUMBER_OVERRIDE } $counterUrl = "$env:AGENT_CONTAINER_URL/buildcounter-v$($env:LINE_MAJOR).txt" $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -Override $override $csproj = Get-Content $env:CSPROJ_PATH -Raw if ($csproj -match '(\d+\.\d+)') { $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 # 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 and test needs: reserve runs-on: windows-latest steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # SDK 10 = single build toolchain (parity with local build.ps1 / global.json). - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' - name: Restore dependencies run: | dotnet restore ${{ env.AGENT_PROJECT }} dotnet restore ${{ env.DIALOG_PROJECT }} dotnet restore ${{ env.DECISIONCORE_TEST_PROJECT }} dotnet restore ${{ env.CORE_TEST_PROJECT }} dotnet restore ${{ env.DIALOG_TEST_PROJECT }} # Every agent suite runs before the release build. ORDER AND BUILD_NUMBER ARE LOAD-BEARING: # Agent.V2.Core.Tests references the agent exe, so the test build compiles it. With the # reserved number it stamps the release version and the Build Agent step is up to date; run # after that step or without the number, it would rewrite the binaries with another version. - name: Run agent tests (all suites) shell: pwsh env: BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }} run: | foreach ($project in @($env:DECISIONCORE_TEST_PROJECT, $env:CORE_TEST_PROJECT, $env:DIALOG_TEST_PROJECT)) { dotnet test $project --configuration Release --no-restore --verbosity quiet if ($LASTEXITCODE -ne 0) { throw "Tests failed: $project" } } - name: Build Agent run: dotnet build ${{ env.AGENT_PROJECT }} --configuration Release --no-restore env: BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }} - name: Build SummaryDialog run: dotnet build ${{ env.DIALOG_PROJECT }} --configuration Release --no-restore # One archive per output folder, unsigned. ZipFile rather than Compress-Archive, which # skips hidden files. - name: Package build output shell: pwsh run: | $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem $outDir = Join-Path $env:RUNNER_TEMP 'agent-unsigned' New-Item -Path $outDir -ItemType Directory -Force | Out-Null [System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:AGENT_OUTPUT), (Join-Path $outDir 'agent.zip')) [System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:DIALOG_OUTPUT), (Join-Path $outDir 'dialog.zip')) Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" } - name: Upload unsigned build output uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent-unsigned path: ${{ runner.temp }}/agent-unsigned if-no-files-found: error retention-days: 1 # A stable release publishes the customer scripts as well, so it waits for the gates their # own publish waits for. Next to the build; skipped when nothing goes out as stable. gates: if: inputs.publish_as_stable uses: $/.github/workflows/bootstrap-script-gates.yml # Trust zone: only this repository's scripts, pinned actions and the pinned signing tool # run here -- never a restore or build. The build output arrives as data. The runner # image's Windows SDK signtool is used read-only, to verify third-party signatures. # # Runs in the `release` environment: both identities trust its subject, and it admits main # only. No federated credential trusts the ref, so a job without an environment can neither # sign nor write storage, whatever its permissions say. release: name: Sign and publish needs: [reserve, build, gates] # A skipped gates job (no stable publish) lets the release run; a failed or cancelled one stops it. if: ${{ !cancelled() && needs.build.result == 'success' && (needs.gates.result == 'success' || needs.gates.result == 'skipped') }} runs-on: windows-latest environment: release permissions: contents: write # tag + GitHub Release id-token: write # code-signing and storage logins, keyless Sigstore signing attestations: write # store the provenance attestation env: AGENT_VERSION: ${{ needs.reserve.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Storage, legacy mirror, docs link, timestamp server: all from the endpoint registry. - name: Load endpoints shell: pwsh run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv - name: Download unsigned build output uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: agent-unsigned path: ${{ runner.temp }}/agent-unsigned # Only the two expected archives, each into its own fresh output folder; the .NET # extractor refuses entries that would land outside it. Never unpack into the checkout # root: an archive could otherwise replace the scripts this job runs below. - name: Unpack build output shell: pwsh run: | $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem foreach ($pair in @(@('agent.zip', $env:AGENT_OUTPUT), @('dialog.zip', $env:DIALOG_OUTPUT))) { $archive = Join-Path $env:RUNNER_TEMP "agent-unsigned/$($pair[0])" $target = Join-Path $env:GITHUB_WORKSPACE $pair[1] if (Test-Path $target) { throw "$($pair[1]) already exists -- refusing to unpack over it." } [System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $target) Write-Host "Unpacked $($pair[0]) into $($pair[1])" } # 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 # Every storage write below (agent ZIP, manifests, AdminConfiguration) is authorized by # this federated identity -- no SAS secret. Only the legacy mirror still needs a SAS: # that account lives in the pre-migration tenant, where this principal has no identity. - 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 }} # Runtime for AzureSignTool only; nothing is built in this job. - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' # Version pinned; roll-forward because the tool targets an older runtime than the SDK above. - name: Install AzureSignTool env: DOTNET_ROLL_FORWARD: LatestMajor run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1 # Authenticode over everything we author; third-party assemblies keep their own signature. # # THE POSITION IS LOAD-BEARING: signing rewrites the PE file, so it must run before the # ZIP and every SHA-256 in the next step -- those hashes are the bootstrap's integrity # oracle, and hashes taken before signing match no downloaded file. # # $LASTEXITCODE is checked explicitly: PowerShell does not throw on a native non-zero exit. - name: Sign agent binaries (Authenticode) shell: pwsh env: DOTNET_ROLL_FORWARD: LatestMajor run: | $ErrorActionPreference = 'Stop' $targets = @() $targets += Get-ChildItem -Path "$env:AGENT_OUTPUT/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File $targets += Get-ChildItem -Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe" -File if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.Agent.exe')) { throw "AutopilotMonitor.Agent.exe not found in $env:AGENT_OUTPUT -- nothing to sign." } if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.SummaryDialog.exe')) { throw "AutopilotMonitor.SummaryDialog.exe not found in $env:DIALOG_OUTPUT -- nothing to sign." } $targets | ForEach-Object { Write-Host "Signing $($_.Name)" } & 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 Agent" ` --description-url "$env:WWW_BASE_URL" ` ($targets | ForEach-Object { $_.FullName }) if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published." } - name: Stage release payload shell: pwsh run: | $version = $env:AGENT_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" # Third-party code enters the payload only with its vendor's own signature: every PE that # is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded # only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows # catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An # unsigned file or a new vendor stops the release until a person decides. This guards the # bytes we did not write; a build that alters our own code is D-346's remaining risk. - name: Check third-party signatures shell: pwsh run: | $ErrorActionPreference = 'Stop' $allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)') $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue | Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } | Sort-Object { [version]$_.Directory.Parent.Name } -Descending | Select-Object -First 1 if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." } Write-Host "Using $($signtool.FullName)" $failed = $false $checked = 0 foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) { # A PE by content (MZ header), not by extension. $head = [byte[]]::new(2) $stream = [System.IO.File]::OpenRead($file.FullName) try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() } if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue } if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them $checked++ $output = & $signtool.FullName verify /pa $file.FullName 2>&1 if ($LASTEXITCODE -ne 0) { Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())" $failed = $true continue } $subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject $vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' } if ($vendor -notin $allowedVendors) { Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist." $failed = $true continue } Write-Host "$($file.Name): embedded signature valid, vendor $vendor" } if ($failed) { throw "Third-party signature check failed. Nothing was published." } Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor." # The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every # other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when # this catalog is ours and matches the extracted files exactly. Created AFTER signing and # BEFORE the ZIP -- the same load-bearing position as the signing step above. - name: Create and sign payload catalog shell: pwsh env: DOTNET_ROLL_FORWARD: LatestMajor run: | $ErrorActionPreference = 'Stop' $stagingDir = 'staging/agent' $catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) { throw "$stagingDir already contains $env:PAYLOAD_CATALOG." } New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null & 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 Agent" ` --description-url "$env:WWW_BASE_URL" ` $catalog if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published." } Copy-Item -Path $catalog -Destination $stagingDir $count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count Write-Host "Payload catalog signed: $count files" - name: Create release artifact id: artifact shell: pwsh run: | $version = $env:AGENT_VERSION $stagingDir = "staging/agent" $manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json $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. # bootstrapVersion is seeded here so the published manifest is never briefly # missing it; Publish-BootstrapScripts.ps1 owns and re-asserts the field afterwards. $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)" # The customer-facing scripts (stable + dev render, test script, grant script) are # published by .github/scripts/Publish-BootstrapScripts.ps1 -- see the upload step. "versioned_zip_release=$versionedZip" >> $env:GITHUB_OUTPUT "sha256=$sha256Hash" >> $env:GITHUB_OUTPUT "exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT # Verified against the STAGING copy -- the exact bytes that ship. The invariant is "every # AutopilotMonitor PE in the ZIP is signed", so a project added later cannot ride along # unsigned. A missing timestamp fails too: without it the signature dies with the cert. - name: Verify Authenticode signatures shell: pwsh run: | $ErrorActionPreference = 'Stop' $files = Get-ChildItem -Path "staging/agent/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File if ($files.Count -lt 2) { throw "Expected signed binaries in staging/agent, found $($files.Count)." } $failed = $false $thumbprints = @{} foreach ($file in $files) { $sig = Get-AuthenticodeSignature -FilePath $file.FullName if ($sig.Status -ne 'Valid') { Write-Host "::error file=$($file.Name)::Signature status is '$($sig.Status)' -- expected 'Valid'." $failed = $true continue } if (-not $sig.TimeStamperCertificate) { Write-Host "::error file=$($file.Name)::Signature carries no RFC-3161 timestamp -- it would expire with the certificate." $failed = $true continue } if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) { Write-Host "::error file=$($file.Name)::Signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'." $failed = $true continue } $thumbprints[$sig.SignerCertificate.Thumbprint] = $true Write-Host "$($file.Name): Valid, timestamped $($sig.TimeStamperCertificate.Subject.Split(',')[0]), signer $($sig.SignerCertificate.Subject)" } if ($thumbprints.Count -gt 1) { Write-Host "::error::Staged binaries carry $($thumbprints.Count) different signing certificates -- expected exactly one." $failed = $true } if ($failed) { throw "Signature verification failed. Nothing was published." } Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')" # The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the # archiver did (Compress-Archive skips hidden files) fails here instead of on a device. # Windows PowerShell 5.1 and the installer's own functions (dot-sourced; its entry guard # keeps that side-effect free) -- the very code the installer and the OOBE go-script run. - name: Verify payload catalog (device check) shell: powershell env: RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }} run: | $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem $payload = Join-Path $env:RUNNER_TEMP 'verify-payload' if (Test-Path $payload) { throw "$payload already exists." } [System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload) . (Resolve-Path $env:BOOTSTRAP_SOURCE).Path if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $ExpectedPublisher -ne $env:CODE_SIGN_EXPECTED_SUBJECT) { throw "Installer pin '$ExpectedPublisher' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'." } if ($AgentCatalogName -ne $env:PAYLOAD_CATALOG) { throw "Installer catalog name '$AgentCatalogName' differs from PAYLOAD_CATALOG '$env:PAYLOAD_CATALOG'." } # Assign first, then flatten: the function returns its list as ONE object (comma # operator), so @(Get-AgentPayloadRejections ...) would nest it inside another array. $problems = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName $problems = @($problems) # Release-only checks a device need not repeat: a timestamp (without it the signature # dies with the certificate) and the same certificate as the binaries. $signature = Get-AuthenticodeSignature (Join-Path $payload $AgentCatalogName) if (-not $signature.TimeStamperCertificate) { $problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate' } $exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) { $problems += 'catalog and agent exe carry different signing certificates' } if ($problems.Count -gt 0) { $problems | ForEach-Object { Write-Host "::error::$_" } throw "Payload catalog verification failed. Nothing was published." } Write-Host "Payload catalog verified on the final ZIP with the installer's own check, signer $($signature.SignerCertificate.Subject)" - name: Upload to Azure Blob Storage if: inputs.upload_blob shell: pwsh env: ZIP_SHA256: ${{ steps.artifact.outputs.sha256 }} EXE_SHA256: ${{ steps.artifact.outputs.exe_sha256 }} run: | $containerUrl = $env:AGENT_CONTAINER_URL $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 # 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 = $env:LEGACY_AGENT_CONTAINER_URL $legacySasToken = "${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}".TrimStart('?') function Send-Blob { param([string]$Local, [string]$BlobName, [string]$ContentType) $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 "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $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)" } } } # Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts # read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no # scripts; the channel check in `reserve` already refused publish_as_stable/create_release. if ($env:CHANNEL -eq 'dev') { Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip" Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json" Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip" Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json" Write-Host "DEV CHANNEL: lab blobs written, nothing else touched." exit 0 } # 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" # Customer-facing scripts: same publisher and same gates (the gates job) as # publish-scripts.yml, so a release lands a set that is consistent with what is # already live (dev render, version-bump guard, bootstrapVersion oracles, alias verification). # Runs AFTER version.json so its bootstrapVersion read sees this build's manifest. # Signed with the same identity: the device-side loader verifies the publisher # of what it downloads, and the publisher refuses to upload unsigned scripts. ./.github/scripts/Publish-BootstrapScripts.ps1 ` -LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' ` -SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN ` -KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL ` -CertificateName $env:CODE_SIGN_CERTIFICATE_NAME ` -ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT } # Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle # must never point at a build that is not actually in the stable namespace. # LatestBootstrapV2ScriptVersion is NOT set here: it belongs to the script publisher # above, which also owns it on script-only pushes. One writer per field. if ('${{ inputs.publish_as_stable }}' -ne 'true') { Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)" } else { $sha256 = $env:ZIP_SHA256 $exeSha256 = $env:EXE_SHA256 $agentVersion = $env:AGENT_VERSION $tableUrl = $env:ADMIN_CONFIG_URL $tableBody = @{ LatestAgentV2Version = $agentVersion LatestAgentV2Sha256 = $sha256 LatestAgentV2ExeSha256 = $exeSha256 } Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers ($authHeaders + @{ "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" } # Sigstore keyless provenance: binds the artifact digests to this workflow run (repo, ref, # commit, workflow file) in a public transparency log. The digests cover the identical # bytes uploaded to blob (versioned AND stable), so one attestation covers all copies. # Complements Authenticode rather than replacing it: the signature says who published a # binary, the attestation which build produced these exact bytes. Both EXEs are subjects # of their own so an extracted binary can be verified without the ZIP. # Verify: gh attestation verify --repo okieselbach/AutopilotMonitor # Skipped on runs that publish nothing -- no log entry for bytes nobody receives. - name: Attest build provenance if: inputs.upload_blob || inputs.create_release uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | ${{ steps.artifact.outputs.versioned_zip_release }} staging/agent/AutopilotMonitor.Agent.exe staging/agent/AutopilotMonitor.SummaryDialog.exe - name: Upload build artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }} path: ${{ steps.artifact.outputs.versioned_zip_release }} - name: Upload version metadata uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: version-metadata path: ${{ env.MANIFEST_NAME }} # First-party `gh` instead of a third-party action: this job holds contents:write, # id-token:write and attestations:write, so no code we do not control runs here. # The "What's new" section comes ONLY from the release_notes input -- CI never # composes customer-facing text from commits. An empty input writes a generic # fallback that links to the docs changelog and carries the HTML-comment marker # below; the operator tooling scans release bodies for that marker to find the # last release with real notes, so the next noted release covers the gap. # Never rename the marker. --generate-notes is not used: it lists pull requests # only (Dependabot noise); the compare link to the previous tag replaces it. - name: Create GitHub Release if: inputs.create_release shell: pwsh env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.reserve.outputs.tag }} RELEASE_NAME: Autopilot Monitor Agent ${{ needs.reserve.outputs.version }} RELEASE_PRERELEASE: ${{ inputs.prerelease }} RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }} RELEASE_NOTES: ${{ inputs.release_notes }} RELEASE_BODY_TAIL: | ### Version Info - **Version:** ${{ needs.reserve.outputs.version }} - **Build Number:** ${{ needs.reserve.outputs.build_number }} - **SHA-256:** `${{ steps.artifact.outputs.sha256 }}` - **Built from:** ${{ github.sha }} - **Published as stable:** ${{ inputs.publish_as_stable }} ### Provenance The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify: ``` gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor ``` run: | $ErrorActionPreference = 'Stop' $notes = "$env:RELEASE_NOTES".Trim() if ($notes -eq '') { $notes = "Minor enhancements and bug fixes. See the [Agent Changelog]($env:DOCS_BASE_URL/changelog/agent-changelog) for details.`n" } # Previous release for the compare link. Fail-soft: the first release has none, # and a listing hiccup must never fail a release. $prevTag = '' $eap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $prevTag = "$(gh release list --repo $env:GITHUB_REPOSITORY --limit 1 --exclude-drafts --json tagName -q '.[0].tagName' 2>$null)".Trim() } catch { $prevTag = '' } $ErrorActionPreference = $eap $compare = '' if ($prevTag -ne '' -and $prevTag -ne $env:RELEASE_TAG) { $compare = "`n`n**Full changelog:** https://github.com/$env:GITHUB_REPOSITORY/compare/$prevTag...$env:RELEASE_TAG`n" } $body = "### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare $notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md' Set-Content -Path $notesFile -Value $body -Encoding utf8 -NoNewline $ghArgs = @( 'release', 'create', $env:RELEASE_TAG, $env:RELEASE_ZIP, $env:MANIFEST_NAME, '--repo', $env:GITHUB_REPOSITORY, '--target', $env:GITHUB_SHA, '--title', $env:RELEASE_NAME, '--notes-file', $notesFile ) if ($env:RELEASE_PRERELEASE -eq 'true') { $ghArgs += '--prerelease' } & gh @ghArgs if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" } Write-Host "Release $env:RELEASE_TAG created"