mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(ci): shared build-counter blob, CI build parity, ZIP provenance attestation
Build numbers now come from a public counter blob (buildcounter-v2.txt, reserve-before-build with ETag-CAS) shared by local build.ps1 and CI, so the two paths can never mint the same number. The CI step that committed buildcounter.txt bumps to main is gone; the repo file is now a gitignored offline fallback for -SkipUpload smoke builds only. CI parity fixes vs the local build: - ship SummaryDialog.exe.config in the ZIP (WPF AppContextSwitchOverrides; without it the dialog dies with 0x8007045A when spawned from SYSTEM) - verify EXE version == manifest == reserved build number before zipping - update the AdminConfiguration hash-oracle only on publish_as_stable CI-built release ZIPs now carry a GitHub Artifact Attestation (Sigstore keyless provenance, actions/attest-build-provenance@v4). Doc: docs/agent/build-counter-blob.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
env:
|
||||
LINE_MAJOR: 2
|
||||
@@ -37,7 +39,6 @@ env:
|
||||
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
|
||||
COUNTER_FILE: src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
|
||||
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
|
||||
@@ -62,18 +63,68 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Determine build number
|
||||
# Build numbers come from the shared counter blob (single source of truth for CI
|
||||
# AND local build.ps1). Reserve-before-build with ETag-CAS: concurrent builds can
|
||||
# never mint the same number; a failed build burns a number, which is harmless.
|
||||
# The blob is public-read (like the rest of the container); writes need the SAS.
|
||||
- name: Reserve build number (counter blob CAS)
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$inputBuildNumber = "${{ inputs.build_number }}"
|
||||
if ($inputBuildNumber -ne "") {
|
||||
$buildNumber = [int]$inputBuildNumber
|
||||
Write-Host "Using manual build number: $buildNumber"
|
||||
} else {
|
||||
$current = [int](Get-Content $env:COUNTER_FILE).Trim()
|
||||
$buildNumber = $current + 1
|
||||
Write-Host "Auto-incremented build number: $current -> $buildNumber"
|
||||
$counterBlob = "buildcounter-v$env:LINE_MAJOR.txt"
|
||||
$counterUrl = "https://autopilotmonitor.blob.core.windows.net/agent/$counterBlob"
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}"
|
||||
$override = "${{ inputs.build_number }}"
|
||||
|
||||
function Get-Counter {
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $counterUrl -Method Get
|
||||
$raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content }
|
||||
# If-Match requires the quoted ETag form; pwsh strips the quotes on read.
|
||||
$etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
|
||||
return @{ Value = [int]$raw.Trim(); ETag = $etag }
|
||||
} catch {
|
||||
$status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
|
||||
if ($status -eq 404) {
|
||||
Write-Error "Counter blob '$counterBlob' not found. Seed it once with the last used build number (see docs/agent/build-counter-blob.md) -- do NOT seed from the repo's buildcounter.txt, it is a stale offline fallback."
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
$buildNumber = $null
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
||||
$counter = Get-Counter
|
||||
if ($override -ne '') {
|
||||
$target = [int]$override
|
||||
if ($target -le $counter.Value) {
|
||||
# Re-build of an already-reserved number: counter stays monotonic, no write.
|
||||
Write-Host "Manual build number $target <= counter $($counter.Value) -- counter blob unchanged"
|
||||
$buildNumber = $target
|
||||
break
|
||||
}
|
||||
Write-Host "Manual build number $target > counter $($counter.Value) -- advancing counter"
|
||||
} else {
|
||||
$target = $counter.Value + 1
|
||||
}
|
||||
try {
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
|
||||
Invoke-RestMethod -Uri "$counterUrl`?$sasToken" -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
|
||||
$buildNumber = $target
|
||||
Write-Host "Reserved build number ${target} (CAS write ok, attempt $attempt)"
|
||||
break
|
||||
} catch {
|
||||
$status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
|
||||
if ($status -eq 412) {
|
||||
Write-Host "CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying"
|
||||
Start-Sleep -Seconds ($attempt * 2)
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($null -eq $buildNumber) {
|
||||
Write-Error "Could not reserve a build number after 5 CAS attempts"
|
||||
}
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
@@ -107,18 +158,6 @@ jobs:
|
||||
- name: Run Agent unit tests
|
||||
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
|
||||
|
||||
- name: Update buildcounter.txt
|
||||
if: inputs.build_number == ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = "${{ steps.version.outputs.build_number }}"
|
||||
Set-Content -Path $env:COUNTER_FILE -Value $buildNumber -NoNewline
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add $env:COUNTER_FILE
|
||||
git commit -m "chore(v$env:LINE_MAJOR): bump build counter to $buildNumber"
|
||||
git push
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
shell: pwsh
|
||||
@@ -134,11 +173,36 @@ jobs:
|
||||
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.pdb" -Destination $stagingDir -Force -ErrorAction SilentlyContinue
|
||||
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
|
||||
@@ -162,13 +226,7 @@ jobs:
|
||||
Write-Warning "Could not parse bootstrap `$ScriptVersion from $env:BOOTSTRAP_SOURCE"
|
||||
}
|
||||
|
||||
# Enrich the per-line manifest emitted by MSBuild (version-v2.json)
|
||||
$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
|
||||
# Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above)
|
||||
$manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
|
||||
if ($bootstrapVersion) {
|
||||
$manifest | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force
|
||||
@@ -239,9 +297,12 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Update AdminConfiguration LatestAgentV2* (always when uploading)
|
||||
# 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 }}"
|
||||
if ([string]::IsNullOrEmpty($tableSasToken) -or $tableSasToken -eq "XXX") {
|
||||
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 }}"
|
||||
@@ -265,6 +326,15 @@ jobs:
|
||||
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/Autopilot-Monitor
|
||||
- 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@v6
|
||||
with:
|
||||
@@ -302,6 +372,12 @@ jobs:
|
||||
- **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/Autopilot-Monitor/attestations) (Sigstore keyless). Verify:
|
||||
```
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/Autopilot-Monitor
|
||||
```
|
||||
draft: false
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
files: |
|
||||
|
||||
+4
-2
@@ -14,8 +14,10 @@ bld/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Build counter files (auto-incremented on each build) if ignored every dev has its own buildcounter, but I keep a global one.
|
||||
#buildcounter.txt
|
||||
# Build counter files: the authoritative counter is the shared blob
|
||||
# (agent container, buildcounter-v{N}.txt, ETag-CAS — see docs/agent/build-counter-blob.md).
|
||||
# The local file is only an offline fallback for -SkipUpload/bare-dotnet smoke builds.
|
||||
buildcounter.txt
|
||||
|
||||
# Visual Studio cache/options
|
||||
.vs/
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Agent Build Numbers — Shared Counter Blob with ETag-CAS
|
||||
description: Why agent build numbers come from a public counter blob reserved before the build, how the compare-and-swap protocol works, and what the repo's buildcounter.txt still does.
|
||||
resource: /.github/workflows/build-agent.yml
|
||||
tags:
|
||||
- agent
|
||||
- build
|
||||
- ci
|
||||
- release
|
||||
- versioning
|
||||
timestamp: 2026-07-17T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Agent Build Numbers — Shared Counter Blob
|
||||
|
||||
Agent (V2 line) build numbers — the `<n>` in `2.0.<n>` — are minted from a single
|
||||
shared counter blob so that **local builds (`scripts/Deployment/build.ps1`) and CI
|
||||
builds (`.github/workflows/build-agent.yml`) can never collide**. Both build paths
|
||||
stay first-class: local daily-ops builds remain possible, CI builds add provenance
|
||||
attestation on top.
|
||||
|
||||
# Schema
|
||||
|
||||
- **Counter blob**: `https://autopilotmonitor.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
|
||||
(per release line, analogous to `version-v{LINE}.json`). Content: the **last used**
|
||||
build number as plain text. Public-read like the rest of the container (the number
|
||||
is visible in `version-v{LINE}.json` anyway); writes require the container SAS.
|
||||
- **Protocol — reserve before build, ETag-CAS**:
|
||||
1. Anonymous `GET` the blob, capture value `N` and the `ETag`.
|
||||
2. `PUT N+1` with `If-Match: <etag>` (quoted form — pwsh strips the quotes on read,
|
||||
they must be re-added or the request is rejected client-side).
|
||||
3. `412 Precondition Failed` → another build reserved concurrently → re-read, retry
|
||||
(5 attempts, linear backoff).
|
||||
4. Build with `BUILD_NUMBER=N+1` in the environment. The csproj honors
|
||||
`BUILD_NUMBER` and then skips its `PersistBuildCounter` target, so the repo file
|
||||
is not touched.
|
||||
- **Failed builds burn a number.** That is intentional: uniqueness matters, density
|
||||
does not. Reserve-before-build is what makes concurrent minting impossible.
|
||||
- **CI manual override** (`build_number` input): numbers `<=` counter are re-builds
|
||||
and leave the blob unchanged; numbers `>` counter advance it (monotonic guard).
|
||||
- **Seeding** (one-time per line): `PUT <last used number>` with `If-None-Match: *`
|
||||
so an existing counter can never be clobbered. V2 was seeded with `1356` on
|
||||
2026-07-17.
|
||||
|
||||
# Consequences
|
||||
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt` is now only an **offline
|
||||
fallback** for `-SkipUpload` smoke builds and bare `dotnet build` (where the version
|
||||
does not matter). It is never committed anymore; the CI step that pushed counter
|
||||
bumps to `main` was removed.
|
||||
- `AdminConfiguration.LatestAgentV2*` (backend hash-oracle) is updated **only on
|
||||
stable cutover** (`publish_as_stable` / default `build.ps1` run without
|
||||
`-VersionedOnly`/`-Dev`) — it must never point at a build that is not actually in
|
||||
the stable namespace.
|
||||
- CI builds additionally attest build provenance for the release ZIP via
|
||||
`actions/attest-build-provenance` (Sigstore keyless, bound to the workflow OIDC
|
||||
identity). The digest covers the identical bytes uploaded to blob, so one
|
||||
attestation covers all copies. Verify:
|
||||
`gh attestation verify <zip> --repo okieselbach/Autopilot-Monitor`. Local builds
|
||||
cannot produce this attestation — provenance is a CI-only property.
|
||||
|
||||
# Citations
|
||||
|
||||
- `.github/workflows/build-agent.yml` — "Reserve build number (counter blob CAS)" step
|
||||
- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — `Request-BuildNumber`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj` —
|
||||
`BUILD_NUMBER` property + `PersistBuildCounter` target
|
||||
@@ -16,6 +16,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Agent
|
||||
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
|
||||
# Security & Identity
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-17
|
||||
|
||||
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
|
||||
|
||||
## 2026-07-16
|
||||
|
||||
* **Creation**: Added `agent/autopilot-ztd-diagnostics.md` — reference for Windows' own ZTD diagnostic surfaces (ModernDeployment Autopilot event IDs incl. 807/809/815/908, the `Diagnostics\Autopilot` registry key, deployment-service endpoints, known-issue error-code map with KB fixes). Backs the agent's `ZtdEvidence` collector (`ztdVerdict` on `autopilot_profile_missing`) and the backend known-issue rules; sources carry a re-check RSS feed.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
1355
|
||||
Reference in New Issue
Block a user