From 6d7783566f7538d532cf3103173a247ef6be5ae9 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 17 Jul 2026 10:49:18 +0200 Subject: [PATCH] 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 --- .github/workflows/build-agent.yml | 140 ++++++++++++++---- .gitignore | 6 +- docs/agent/build-counter-blob.md | 68 +++++++++ docs/index.md | 1 + docs/log.md | 4 + .../buildcounter.txt | 1 - 6 files changed, 185 insertions(+), 35 deletions(-) create mode 100644 docs/agent/build-counter-blob.md delete mode 100644 src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index 06569752..ce54ac3c 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -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 --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: | diff --git a/.gitignore b/.gitignore index 18ddecec..6360a689 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/agent/build-counter-blob.md b/docs/agent/build-counter-blob.md new file mode 100644 index 00000000..5dbe0bc3 --- /dev/null +++ b/docs/agent/build-counter-blob.md @@ -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 `` in `2.0.` — 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: ` (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 ` 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 --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 diff --git a/docs/index.md b/docs/index.md index 93cff624..cdb1ea71 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/log.md b/docs/log.md index d1e9780c..77870871 100644 --- a/docs/log.md +++ b/docs/log.md @@ -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. diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt deleted file mode 100644 index 4422c3ed..00000000 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ /dev/null @@ -1 +0,0 @@ -1355