mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10: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>
875 lines
46 KiB
YAML
875 lines
46 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
|
|
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 '<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
|
|
|
|
# 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 <file> --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<!-- release-notes: none -->"
|
|
}
|
|
|
|
# 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"
|