mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c26eca2f01 | |||
| 9f1879a9f3 | |||
| c24c7bf7ef | |||
| 5fac3d6004 | |||
| 7605d2e9f5 | |||
| 7bf5412d63 | |||
| b53ceb1a7c | |||
| 647581b4ff | |||
| db9b1995bc | |||
| 7563435837 | |||
| e39f2abb61 | |||
| 47dad7143f | |||
| d81f41dc72 | |||
| cc6be10532 | |||
| 0ac548bfce | |||
| 9c1d0586ef | |||
| 21ec5e4773 | |||
| e4a5a6654d | |||
| 01bc35aa97 |
+1
-1
Submodule .claude/commands updated: e57da4e431...d004f83c8a
@@ -0,0 +1,51 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the request headers that authorize an Azure Storage REST call with Microsoft Entra.
|
||||
|
||||
.DESCRIPTION
|
||||
One place for the storage OAuth contract shared by every publisher in this repo -- the
|
||||
agent ZIP and MSI, the customer bootstrap scripts, the version manifests and the build
|
||||
counters. Resource id, the az invocation, the service version and the failure message
|
||||
live here so they cannot drift between call sites.
|
||||
|
||||
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
|
||||
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
|
||||
operator's own account for the local build. No secret is read, stored or passed around.
|
||||
|
||||
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
|
||||
below 2017-11-09, and Table requests without a SAS must carry the header at all. 2021-08-06
|
||||
is old enough to be deployed in every region and new enough for everything we send.
|
||||
|
||||
Callers merge the result into their own headers -- `$auth + @{ ... }` yields a new
|
||||
hashtable per request, so nothing is shared across uploads:
|
||||
|
||||
$auth = & "$PSScriptRoot/Get-StorageAuthHeaders.ps1"
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($auth + @{ 'x-ms-blob-type' = 'BlockBlob' }) -Body $bytes
|
||||
|
||||
Acquire once per step and reuse: every call shells out to az (~1s) and the token is valid
|
||||
for the lifetime of a job.
|
||||
|
||||
.OUTPUTS
|
||||
[hashtable] Authorization + x-ms-version.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The data-plane resource, not management.azure.com -- a management token is accepted by
|
||||
# neither the blob nor the table endpoint.
|
||||
$token = az account get-access-token --resource https://storage.azure.com --query accessToken -o tsv 2>$null
|
||||
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw ('Could not acquire an Azure Storage token. In CI this means the azure/login step ' +
|
||||
'is missing or its federated credential does not cover this ref; locally, run ' +
|
||||
'`az login`. A token alone is not enough: the principal also needs Storage Blob ' +
|
||||
'Data Contributor on the container it writes, and Storage Table Data Contributor ' +
|
||||
'on the table -- a missing role shows up later as HTTP 403 AuthorizationPermissionMismatch.')
|
||||
}
|
||||
|
||||
return @{
|
||||
'Authorization' = "Bearer $token"
|
||||
'x-ms-version' = '2021-08-06'
|
||||
}
|
||||
@@ -12,52 +12,78 @@
|
||||
lands a consistent set (ZIP + manifest + scripts).
|
||||
|
||||
Both callers run the same code. The scripts on the download alias are the live
|
||||
copy the WDP bootstrap MSI (Invoke-BootstrapDownload.ps1) downloads on every
|
||||
enrolling device, so "current" is a correctness property, not cosmetics.
|
||||
copy the stage-1 loader downloads on every enrolling device, whether it came from
|
||||
Intune or from the bootstrap MSI, so "current" is a correctness property, not
|
||||
cosmetics.
|
||||
|
||||
Steps, in order:
|
||||
1. Parse $ScriptVersion from the bootstrap source.
|
||||
2. Render the -Dev variant by literal substitution of the two URL/manifest
|
||||
1. Parse $ScriptVersion from the loader and the bootstrap source.
|
||||
2. Render the -Dev variants by literal substitution of the URL/manifest
|
||||
defaults. A missing anchor is a hard failure -- a silently un-substituted
|
||||
dev script would point the dev fleet at the stable agent.
|
||||
3. Version-bump guard: if the published bootstrap differs from the source
|
||||
but carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
3. Version-bump guard: if a published script differs from its source but
|
||||
carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
version.json.bootstrapVersion and the portal's "your script is outdated"
|
||||
hint would all keep asserting a version that is no longer what ships.
|
||||
4. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
|
||||
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
|
||||
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
|
||||
and nothing may touch them afterwards or the signature breaks.
|
||||
6. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
place), mirrored fail-soft to the legacy account.
|
||||
5. Reconcile the two version oracles: version.json.bootstrapVersion
|
||||
(read-modify-write under If-Match, so a concurrent agent release cannot
|
||||
lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
6. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
7. Reconcile the version oracles: version.json.bootstrapVersion and
|
||||
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
release cannot lose its agent fields) and
|
||||
AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
8. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
|
||||
.PARAMETER SasToken
|
||||
Container SAS for the eu storage account with write permission. Leading '?' tolerated.
|
||||
Comparisons against a published script always strip its signature block first
|
||||
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
|
||||
every run; without stripping, the bump guard and the drift check would report a
|
||||
change for every unchanged script.
|
||||
|
||||
.PARAMETER LegacySasToken
|
||||
Container SAS for the legacy account. Optional -- a mirror failure warns, never fails.
|
||||
|
||||
.PARAMETER TableSasToken
|
||||
Table SAS for AdminConfiguration. Optional -- omitted or 'XXX' skips the oracle update.
|
||||
The legacy account lives in a different tenant, so the ambient Entra identity cannot
|
||||
reach it; this is the only credential the publisher still takes.
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
Repository root. Defaults to the parent of .github/scripts.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Run every check and render, upload nothing.
|
||||
Run every check and render, upload nothing. A dry run signs when a token is supplied
|
||||
and skips signing when it is not: the PR gate runs this without any Azure login.
|
||||
|
||||
.PARAMETER SigningToken
|
||||
Azure Key Vault access token for AzureSignTool. MANDATORY for a real publish -- an
|
||||
unsigned customer script is not something this repo publishes. Together with
|
||||
-KeyVaultUrl and -CertificateName.
|
||||
|
||||
.PARAMETER ExpectedPublisher
|
||||
Optional -like pattern the signer subject must match after signing. Pins the publisher
|
||||
instead of accepting any trusted signature; the loader on the device pins the same way.
|
||||
|
||||
.PARAMETER SignedOutputDir
|
||||
Optional directory that receives a copy of every published file exactly as uploaded.
|
||||
Used by CI to offer the signed scripts as a build artifact for local inspection.
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -DryRun
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -SasToken $env:SAS -TableSasToken $env:TABLE_SAS
|
||||
./Publish-BootstrapScripts.ps1 -LegacySasToken $env:LEGACY_SAS -SigningToken $env:AKV_TOKEN `
|
||||
-KeyVaultUrl $env:KV_URL -CertificateName $env:CERT
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$SasToken,
|
||||
[string]$LegacySasToken,
|
||||
[string]$TableSasToken,
|
||||
[string]$RepoRoot,
|
||||
[string]$SigningToken,
|
||||
[string]$KeyVaultUrl,
|
||||
[string]$CertificateName,
|
||||
[string]$TimestampUrl = 'http://timestamp.acs.microsoft.com',
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$SignedOutputDir,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
@@ -66,9 +92,6 @@ $ErrorActionPreference = 'Stop'
|
||||
if (-not $RepoRoot) {
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
}
|
||||
if (-not $DryRun -and [string]::IsNullOrWhiteSpace($SasToken)) {
|
||||
throw 'SasToken is required unless -DryRun is used.'
|
||||
}
|
||||
|
||||
$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent'
|
||||
$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent'
|
||||
@@ -78,8 +101,23 @@ $TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminCo
|
||||
$BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1'
|
||||
$BootstrapBlob = 'Install-AutopilotMonitor.ps1'
|
||||
$DevBlob = 'Install-AutopilotMonitor-Dev.ps1'
|
||||
# Stage 1. Assigned in Intune once, and embedded in the bootstrap MSI; it downloads the
|
||||
# bootstrap above on every device and verifies its publisher before running it.
|
||||
$LoaderSource = Join-Path $RepoRoot 'scripts/Bootstrap/Start-AutopilotMonitor.ps1'
|
||||
$LoaderBlob = 'Start-AutopilotMonitor.ps1'
|
||||
$LoaderDevBlob = 'Start-AutopilotMonitor-Dev.ps1'
|
||||
$ScriptContentType = 'text/plain; charset=utf-8'
|
||||
|
||||
$SigningEnabled = -not [string]::IsNullOrWhiteSpace($SigningToken)
|
||||
if (-not $DryRun -and -not $SigningEnabled) {
|
||||
throw ('Publishing requires -SigningToken (plus -KeyVaultUrl and -CertificateName): the ' +
|
||||
'loader on every device verifies the publisher of what it downloads, so an unsigned ' +
|
||||
'script would be rejected in the field. Only -DryRun may run without signing.')
|
||||
}
|
||||
if ($SigningEnabled -and ([string]::IsNullOrWhiteSpace($KeyVaultUrl) -or [string]::IsNullOrWhiteSpace($CertificateName))) {
|
||||
throw '-SigningToken needs -KeyVaultUrl and -CertificateName.'
|
||||
}
|
||||
|
||||
function Get-HttpStatus {
|
||||
param($ErrorRecord)
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
|
||||
@@ -125,6 +163,132 @@ function Get-PublishBytes {
|
||||
return ,(New-Object System.Text.UTF8Encoding $false).GetBytes($normalised)
|
||||
}
|
||||
|
||||
# A PowerShell signature is appended as a comment block at the end of the file, so the
|
||||
# published bytes are "source + signature". Everything that compares a published script to
|
||||
# its source has to cut that block off first -- the signature carries a timestamp and is
|
||||
# therefore different on every publish, while the script above it is unchanged.
|
||||
function Remove-SignatureBlock {
|
||||
param([string]$Text)
|
||||
$marker = '# SIG # Begin signature block'
|
||||
$index = $Text.IndexOf($marker)
|
||||
if ($index -lt 0) { return $Text }
|
||||
return $Text.Substring(0, $index)
|
||||
}
|
||||
|
||||
# Comparison form: no signature, LF, no trailing blank lines. Used for "is the published
|
||||
# copy still this source" -- never for what gets uploaded.
|
||||
function Get-ComparableText {
|
||||
param([string]$Text)
|
||||
return (Remove-SignatureBlock $Text).Replace("`r`n", "`n").TrimEnd("`n")
|
||||
}
|
||||
|
||||
# Signs the publish set in place: each item's bytes are written to a work directory, signed
|
||||
# there, verified, and read back as the bytes that will be uploaded. Signing has to be the
|
||||
# last transformation -- the signature covers exactly these bytes, so nothing may normalise
|
||||
# or re-encode them afterwards.
|
||||
function Set-PublishSetSignature {
|
||||
param([object[]]$Items, [string]$WorkDir)
|
||||
|
||||
if (-not (Get-Command azuresigntool -ErrorAction SilentlyContinue)) {
|
||||
throw "azuresigntool is not on PATH. Install it with: dotnet tool install --global AzureSignTool --version 7.0.1"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
|
||||
$paths = @()
|
||||
foreach ($item in $Items) {
|
||||
$path = Join-Path $WorkDir $item.BlobName
|
||||
[System.IO.File]::WriteAllBytes($path, [byte[]]$item.Bytes)
|
||||
$paths += $path
|
||||
}
|
||||
|
||||
Write-Host "Signing $($paths.Count) script(s) with certificate '$CertificateName'..."
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url $KeyVaultUrl `
|
||||
--azure-key-vault-accesstoken $SigningToken `
|
||||
--azure-key-vault-certificate $CertificateName `
|
||||
--timestamp-rfc3161 $TimestampUrl `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor bootstrap' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
$paths
|
||||
|
||||
# Native tool: PowerShell does not throw on a non-zero exit, and an unnoticed signing
|
||||
# failure would publish unsigned scripts that every loader in the field then rejects.
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed with exit code $LASTEXITCODE -- nothing was uploaded."
|
||||
}
|
||||
|
||||
foreach ($item in $Items) {
|
||||
$path = Join-Path $WorkDir $item.BlobName
|
||||
$sig = Get-AuthenticodeSignature -FilePath $path
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
throw "Signature on $($item.BlobName) is '$($sig.Status)', expected 'Valid'."
|
||||
}
|
||||
# Without a timestamp the signature dies with the certificate, and these scripts sit
|
||||
# on customer devices far longer than a certificate lifetime.
|
||||
if (-not $sig.TimeStamperCertificate) {
|
||||
throw "Signature on $($item.BlobName) carries no timestamp."
|
||||
}
|
||||
if ($ExpectedPublisher -and $sig.SignerCertificate.Subject -notlike $ExpectedPublisher) {
|
||||
throw "$($item.BlobName) was signed by '$($sig.SignerCertificate.Subject)', expected '$ExpectedPublisher'."
|
||||
}
|
||||
# Plain assignment, NO comma operator: the ',' elsewhere in this file exists because
|
||||
# PowerShell unrolls an array RETURNED from a function, which is not what a property
|
||||
# assignment does -- here it would wrap the byte[] in an Object[] of length 1 and the
|
||||
# upload would fail converting it back.
|
||||
$signedBytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($signedBytes.Length -le $item.Bytes.Length) {
|
||||
throw ("Signed $($item.BlobName) is $($signedBytes.Length) bytes but the unsigned form " +
|
||||
"was $($item.Bytes.Length) -- a signature only ever adds bytes, so this is a " +
|
||||
'read-back bug, not a signing result.')
|
||||
}
|
||||
$item.Bytes = $signedBytes
|
||||
Write-Host " signed $($item.BlobName) ($($signedBytes.Length) bytes, signer $($sig.SignerCertificate.Subject.Split(',')[0]))"
|
||||
}
|
||||
}
|
||||
|
||||
# Aborts when a published script changed behaviour without a version bump. Consumers act on
|
||||
# the version (docs badge, version.json, the portal's outdated-script hint), so an unchanged
|
||||
# version has to mean unchanged behaviour.
|
||||
function Assert-VersionBump {
|
||||
param(
|
||||
[string]$BlobName,
|
||||
[string]$SourceContent,
|
||||
[string]$SourceOrigin,
|
||||
[string]$SourceVersion
|
||||
)
|
||||
|
||||
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BlobName"
|
||||
if ($null -eq $publishedBytes) {
|
||||
Write-Host "No published $BlobName yet -- version-bump guard skipped (first publish)"
|
||||
return
|
||||
}
|
||||
|
||||
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
|
||||
if ((Get-ComparableText $publishedText) -eq (Get-ComparableText $SourceContent)) {
|
||||
Write-Host "Published $BlobName is already this source (signature block aside)"
|
||||
return
|
||||
}
|
||||
|
||||
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BlobName" -Optional
|
||||
|
||||
if ($null -eq $publishedVersion) {
|
||||
Write-Host "::warning::The published $BlobName is not a readable script (no ScriptVersion found). Republishing over it."
|
||||
} elseif ($publishedVersion -ne $SourceVersion) {
|
||||
Write-Host "Version bump: $publishedVersion -> $SourceVersion ($BlobName)"
|
||||
} elseif ((Get-CodeFingerprint -Text (Remove-SignatureBlock $publishedText) -Origin "$AliasUrl/$BlobName") -eq
|
||||
(Get-CodeFingerprint -Text $SourceContent -Origin $SourceOrigin)) {
|
||||
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
|
||||
Write-Host "::warning::$BlobName changed in comments or formatting only; publishing under the unchanged version $SourceVersion."
|
||||
} else {
|
||||
throw ("Version-bump guard: $BlobName changed behaviour but ScriptVersion is still $SourceVersion. " +
|
||||
"Bump it in $SourceOrigin -- the docs badge, version.json and the " +
|
||||
'portal outdated-script hint all read that value, and customers decide from it whether ' +
|
||||
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BootstrapScriptVersion {
|
||||
param([string]$Content, [string]$Origin, [switch]$Optional)
|
||||
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
|
||||
@@ -152,12 +316,16 @@ function Get-CodeFingerprint {
|
||||
return (($tokens | Where-Object { $ignored -notcontains $_.Kind.ToString() } | ForEach-Object { $_.Text }) -join "`n")
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 1. source
|
||||
# ------------------------------------------------------------------ 1. sources
|
||||
$sourceContent = Get-Content $BootstrapSource -Raw
|
||||
$sourceBytes = Get-PublishBytes $sourceContent
|
||||
$scriptVersion = Get-BootstrapScriptVersion -Content $sourceContent -Origin $BootstrapSource
|
||||
Write-Host "Bootstrap script version: $scriptVersion"
|
||||
|
||||
$loaderContent = Get-Content $LoaderSource -Raw
|
||||
$loaderVersion = Get-BootstrapScriptVersion -Content $loaderContent -Origin $LoaderSource
|
||||
Write-Host "Loader script version: $loaderVersion"
|
||||
|
||||
# ------------------------------------------------------------------ 2. dev render
|
||||
$stableUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
@@ -170,33 +338,21 @@ if ($sourceContent.IndexOf($stableUrlLiteral) -lt 0 -or $sourceContent.IndexOf($
|
||||
$devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace($stableManLiteral, $devManLiteral)
|
||||
Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)"
|
||||
|
||||
# ------------------------------------------------------------------ 3. bump guard
|
||||
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BootstrapBlob"
|
||||
if ($null -eq $publishedBytes) {
|
||||
Write-Host 'No published bootstrap yet -- version-bump guard skipped (first publish)'
|
||||
} elseif ((Get-Sha256 $publishedBytes) -eq (Get-Sha256 $sourceBytes)) {
|
||||
Write-Host 'Published bootstrap is already byte-identical to the source'
|
||||
} else {
|
||||
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
|
||||
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" -Optional
|
||||
# The dev loader must fetch the dev bootstrap, or a dev device would run the stable chain.
|
||||
$stableLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
|
||||
$devLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1"'
|
||||
|
||||
if ($null -eq $publishedVersion) {
|
||||
Write-Host "::warning::The published $BootstrapBlob is not a readable bootstrap script (no ScriptVersion found). Republishing over it."
|
||||
} elseif ($publishedVersion -ne $scriptVersion) {
|
||||
Write-Host "Version bump: $publishedVersion -> $scriptVersion"
|
||||
} elseif ((Get-CodeFingerprint -Text $publishedText -Origin "$AliasUrl/$BootstrapBlob") -eq
|
||||
(Get-CodeFingerprint -Text $sourceContent -Origin $BootstrapSource)) {
|
||||
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
|
||||
Write-Host "::warning::$BootstrapBlob changed in comments or formatting only; publishing under the unchanged version $scriptVersion."
|
||||
} else {
|
||||
throw ("Version-bump guard: $BootstrapBlob changed behaviour but ScriptVersion is still $scriptVersion. " +
|
||||
"Bump it in $BootstrapSource -- the docs badge, version.json.bootstrapVersion and the " +
|
||||
'portal outdated-script hint all read that value, and customers decide from it whether ' +
|
||||
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
|
||||
}
|
||||
if ($loaderContent.IndexOf($stableLoaderLiteral) -lt 0) {
|
||||
throw "Loader dev-render: anchor literal missing in $LoaderSource"
|
||||
}
|
||||
$devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiteral)
|
||||
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
|
||||
|
||||
# ------------------------------------------------------------------ 4. upload
|
||||
# ------------------------------------------------------------------ 3. bump guards
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
|
||||
# ------------------------------------------------------------------ 4. publish set
|
||||
# Sources beyond the bootstrap pair, read straight from the repo.
|
||||
$extraSources = @(
|
||||
@{ Path = 'scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1'; BlobName = 'Test-ShouldBootstrapAgent.ps1' }
|
||||
@@ -206,20 +362,38 @@ $extraSources = @(
|
||||
# Uninstall-AutopilotMonitor.ps1 is deliberately NOT published: nothing links it, and
|
||||
# an unauthenticated uninstall script on the public download host is not a feature.
|
||||
|
||||
# Text is the unsigned publish form and stays the reference for every comparison against a
|
||||
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
|
||||
$publishSet = [System.Collections.Generic.List[object]]::new()
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Bytes = (Get-PublishBytes $devContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
|
||||
foreach ($extra in $extraSources) {
|
||||
$extraPath = Join-Path $RepoRoot $extra.Path
|
||||
if (-not (Test-Path $extraPath)) {
|
||||
throw "Publish source missing: $extraPath"
|
||||
}
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Bytes = (Get-PublishBytes (Get-Content $extraPath -Raw)) })
|
||||
$extraText = Get-Content $extraPath -Raw
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Text = $extraText; Bytes = (Get-PublishBytes $extraText) })
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 5. sign
|
||||
# After this point the bytes are final. Normalising, re-encoding or rewriting a line ending
|
||||
# would invalidate the signature, and the device-side loader would refuse the script.
|
||||
if ($SigningEnabled) {
|
||||
Set-PublishSetSignature -Items $publishSet -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-$PID")
|
||||
} else {
|
||||
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
|
||||
}
|
||||
|
||||
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
|
||||
$writeSas = if ($SasToken) { $SasToken.TrimStart('?') } else { '' }
|
||||
|
||||
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
|
||||
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
|
||||
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
|
||||
|
||||
# ------------------------------------------------------------------ 6. upload
|
||||
foreach ($item in $publishSet) {
|
||||
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
|
||||
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
|
||||
@@ -227,11 +401,21 @@ foreach ($item in $publishSet) {
|
||||
[byte[]]$bytes = $item.Bytes
|
||||
$item | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-Sha256 $bytes) -Force
|
||||
|
||||
# Exactly the bytes that go on the wire, for the CI artifact. Written in a dry run too:
|
||||
# that is the run an operator uses to inspect a signed script before it is published.
|
||||
if ($SignedOutputDir) {
|
||||
New-Item -ItemType Directory -Force -Path $SignedOutputDir | Out-Null
|
||||
[System.IO.File]::WriteAllBytes((Join-Path $SignedOutputDir $item.BlobName), $bytes)
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
# Doubles as a drift check: says per blob whether the alias already serves this source.
|
||||
# Doubles as a drift check: says per blob whether the alias already serves this
|
||||
# source. Compared as text without the signature block -- signed bytes differ on
|
||||
# every run, so a byte comparison would report drift for every unchanged script.
|
||||
$served = Get-PublishedBytes "$AliasUrl/$($item.BlobName)"
|
||||
$state = if ($null -eq $served) { 'MISSING on the alias' }
|
||||
elseif ((Get-Sha256 $served) -eq $item.Sha256) { 'already current' }
|
||||
elseif ((Get-ComparableText ([System.Text.Encoding]::UTF8.GetString($served))) -eq
|
||||
(Get-ComparableText $item.Text)) { 'already current' }
|
||||
else { 'STALE on the alias' }
|
||||
Write-Host " [dry-run] $($item.BlobName) ($($bytes.Length) bytes, sha256 $($item.Sha256)) -- $state"
|
||||
continue
|
||||
@@ -241,8 +425,18 @@ foreach ($item in $publishSet) {
|
||||
# with Front Door. Route caching is disabled there, but a stale script paired with a
|
||||
# fresh manifest would fail the bootstrap SHA check -- this keeps that class of bug
|
||||
# impossible even if caching is ever re-enabled.
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = $ScriptContentType; 'x-ms-blob-cache-control' = 'no-cache' }
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)?$writeSas" -Method Put -Headers $headers -Body $bytes | Out-Null
|
||||
#
|
||||
# Content-Disposition: without it a text/plain blob opens as a web page in every
|
||||
# browser, and the documented "download the script" step silently becomes "copy this
|
||||
# text". The header makes the link a download everywhere, and command-line clients
|
||||
# (the loader, the MSI, curl) ignore it.
|
||||
$headers = @{
|
||||
'x-ms-blob-type' = 'BlockBlob'
|
||||
'Content-Type' = $ScriptContentType
|
||||
'x-ms-blob-cache-control' = 'no-cache'
|
||||
'x-ms-blob-content-disposition' = ('attachment; filename="{0}"' -f $item.BlobName)
|
||||
}
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)" -Method Put -Headers ($authHeaders + $headers) -Body $bytes | Out-Null
|
||||
|
||||
# Read straight back from the blob (authoritative, no CDN in the way) before touching the
|
||||
# mirror or the next file. The alias verification at the end would catch a bad body too,
|
||||
@@ -266,7 +460,7 @@ foreach ($item in $publishSet) {
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 5. version oracles
|
||||
# ------------------------------------------------------------------ 7. version oracles
|
||||
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
|
||||
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
|
||||
# agent release cannot lose its version/sha256 fields.
|
||||
@@ -278,11 +472,15 @@ if (-not $DryRun) {
|
||||
} else { [string]$manifestResp.Content }
|
||||
$manifest = $manifestRaw | ConvertFrom-Json
|
||||
|
||||
if ($manifest.bootstrapVersion -eq $scriptVersion) {
|
||||
Write-Host "version.json already reports bootstrapVersion $scriptVersion"
|
||||
if ($manifest.bootstrapVersion -eq $scriptVersion -and $manifest.loaderVersion -eq $loaderVersion) {
|
||||
Write-Host "version.json already reports bootstrapVersion $scriptVersion and loaderVersion $loaderVersion"
|
||||
} else {
|
||||
$etag = '"' + ([string]($manifestResp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
|
||||
$manifest | Add-Member -NotePropertyName 'bootstrapVersion' -NotePropertyValue $scriptVersion -Force
|
||||
# Additive: no consumer reads loaderVersion yet. It exists so support can tell
|
||||
# which stage-1 file a device was assigned -- stage 2 is always current by
|
||||
# construction, so its version says nothing about the customer's Intune copy.
|
||||
$manifest | Add-Member -NotePropertyName 'loaderVersion' -NotePropertyValue $loaderVersion -Force
|
||||
$body = [System.Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Compress))
|
||||
$manifestHeaders = @{
|
||||
'x-ms-blob-type' = 'BlockBlob'
|
||||
@@ -290,8 +488,8 @@ if (-not $DryRun) {
|
||||
'x-ms-blob-cache-control' = 'no-cache'
|
||||
'If-Match' = $etag
|
||||
}
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/version.json?$writeSas" -Method Put -Headers $manifestHeaders -Body $body | Out-Null
|
||||
Write-Host "version.json bootstrapVersion -> $scriptVersion (agent fields untouched)"
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/version.json" -Method Put -Headers ($authHeaders + $manifestHeaders) -Body $body | Out-Null
|
||||
Write-Host "version.json bootstrapVersion -> $scriptVersion, loaderVersion -> $loaderVersion (agent fields untouched)"
|
||||
|
||||
if ($legacySas) {
|
||||
try {
|
||||
@@ -310,11 +508,8 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
$tableSas = if ($TableSasToken) { $TableSasToken.TrimStart('?') } else { '' }
|
||||
if ($DryRun) {
|
||||
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
} elseif ([string]::IsNullOrEmpty($tableSas) -or $tableSas -eq 'XXX') {
|
||||
Write-Host 'SKIPPED: AdminConfiguration update -- set AZURE_TABLE_ADMIN_SAS_TOKEN to enable the portal version oracle'
|
||||
} else {
|
||||
$tableHeaders = @{
|
||||
'Content-Type' = 'application/json'
|
||||
@@ -322,11 +517,11 @@ if ($DryRun) {
|
||||
'If-Match' = '*'
|
||||
}
|
||||
$tableBody = @{ LatestBootstrapV2ScriptVersion = $scriptVersion } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$TableUrl`?$tableSas" -Method Merge -Headers $tableHeaders -Body $tableBody | Out-Null
|
||||
Invoke-RestMethod -Uri $TableUrl -Method Merge -Headers ($authHeaders + $tableHeaders) -Body $tableBody | Out-Null
|
||||
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 6. verify via alias
|
||||
# ------------------------------------------------------------------ 8. verify via alias
|
||||
if (-not $DryRun) {
|
||||
$pending = [System.Collections.ArrayList]::new()
|
||||
$publishSet | ForEach-Object { [void]$pending.Add($_) }
|
||||
@@ -358,7 +553,8 @@ if ($env:GITHUB_STEP_SUMMARY) {
|
||||
$summary = @(
|
||||
"### Bootstrap scripts: $mode",
|
||||
'',
|
||||
"Bootstrap script version: **$scriptVersion**",
|
||||
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
|
||||
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
|
||||
''
|
||||
) + ($publishSet | ForEach-Object { "- $($_.BlobName) -- sha256 $($_.Sha256)" })
|
||||
($summary -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
A failed build burns a number, which is harmless -- uniqueness matters,
|
||||
density does not.
|
||||
|
||||
The blob is public-read; only the write needs the container SAS.
|
||||
The blob is public-read; only the write needs authorization, and that comes from the
|
||||
ambient Entra identity (see Get-StorageAuthHeaders.ps1) -- no secret is passed in.
|
||||
|
||||
Why a counter and not the published version manifest: manifests are written
|
||||
on publish, not on build (the agent writes version.json only for stable
|
||||
@@ -20,10 +21,7 @@
|
||||
overwrite each other's versioned artifact.
|
||||
|
||||
.PARAMETER CounterUrl
|
||||
Full URL of the counter blob, without SAS.
|
||||
|
||||
.PARAMETER SasToken
|
||||
Container SAS with create+write permission. A leading '?' is tolerated.
|
||||
Full URL of the counter blob.
|
||||
|
||||
.PARAMETER Override
|
||||
Explicit build number instead of counter+1. If it is not greater than the
|
||||
@@ -37,20 +35,19 @@
|
||||
[int] the reserved build number. Progress goes to the host, not the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
$n = ./Request-BuildNumber.ps1 -CounterUrl $url -SasToken $sas
|
||||
$n = ./Request-BuildNumber.ps1 -CounterUrl $url
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CounterUrl,
|
||||
[Parameter(Mandatory = $true)][string]$SasToken,
|
||||
[int]$Override = 0,
|
||||
[int]$MaxAttempts = 5
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...").
|
||||
$writeUrl = $CounterUrl + '?' + $SasToken.TrimStart('?')
|
||||
# The write is Entra-authorized; the read below stays anonymous (public container).
|
||||
$authHeaders = & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1')
|
||||
|
||||
function Get-HttpStatus {
|
||||
param($ErrorRecord)
|
||||
@@ -85,8 +82,8 @@ for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||
$target = if ($Override -gt 0) { $Override } else { $counter.Value + 1 }
|
||||
|
||||
try {
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
|
||||
Invoke-RestMethod -Uri $writeUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
|
||||
$headers = $authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
|
||||
Invoke-RestMethod -Uri $CounterUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
|
||||
Write-Host " Reserved build number $target (CAS write ok, attempt $attempt)"
|
||||
return $target
|
||||
} catch {
|
||||
|
||||
@@ -57,6 +57,13 @@ env:
|
||||
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
|
||||
STABLE_ZIP: AutopilotMonitor-Agent.zip
|
||||
STABLE_MANIFEST: version.json
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
|
||||
# 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 }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -67,12 +74,64 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# 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')"
|
||||
|
||||
# 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. Both federated credentials
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
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 (counter blob, agent ZIP, manifests, AdminConfiguration)
|
||||
# is authorized by this federated identity -- no SAS secret. One login covers the whole
|
||||
# job: the federated credential is bound to the ref, and a release run takes ~2 minutes,
|
||||
# well inside the token lifetime. 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@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
@@ -85,7 +144,6 @@ jobs:
|
||||
# build_number could otherwise break out of the quotes and run arbitrary code.
|
||||
env:
|
||||
BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }}
|
||||
AGENT_SAS_TOKEN: ${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}
|
||||
run: |
|
||||
$override = 0
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -ne '') {
|
||||
@@ -96,7 +154,7 @@ jobs:
|
||||
}
|
||||
|
||||
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken $env:AGENT_SAS_TOKEN -Override $override
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -Override $override
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -129,6 +187,47 @@ jobs:
|
||||
- name: Run Agent unit tests
|
||||
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
|
||||
|
||||
# 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 "https://www.autopilotmonitor.com" `
|
||||
($targets | ForEach-Object { $_.FullName })
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
shell: pwsh
|
||||
@@ -219,12 +318,57 @@ jobs:
|
||||
"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 ', ')"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
|
||||
$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
|
||||
@@ -234,7 +378,6 @@ jobs:
|
||||
|
||||
function Send-Blob {
|
||||
param([string]$Local, [string]$BlobName, [string]$ContentType)
|
||||
$url = "$containerUrl/$BlobName`?$sasToken"
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Local)
|
||||
# no-cache on EVERY blob: they all rotate in place (even the per-line ZIP is
|
||||
# overwritten each build), and the download alias fronts them with Front Door.
|
||||
@@ -248,7 +391,7 @@ jobs:
|
||||
$md5.Dispose()
|
||||
}
|
||||
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1MB, 2)) MB)..."
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers $headers -Body $bytes
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $headers) -Body $bytes
|
||||
Write-Host " uploaded $BlobName"
|
||||
if (-not [string]::IsNullOrEmpty($legacySasToken)) {
|
||||
try {
|
||||
@@ -274,47 +417,57 @@ jobs:
|
||||
# to main, 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 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}'
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
|
||||
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
|
||||
-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.
|
||||
$tableSasToken = "${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}".TrimStart('?')
|
||||
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
|
||||
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
|
||||
} elseif ([string]::IsNullOrEmpty($tableSasToken) -or $tableSasToken -eq "XXX") {
|
||||
Write-Host "SKIPPED: Table SAS token not configured -- set AZURE_TABLE_ADMIN_SAS_TOKEN secret to enable backend hash-oracle"
|
||||
} else {
|
||||
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
|
||||
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
|
||||
$agentVersion = "${{ steps.version.outputs.version }}"
|
||||
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken"
|
||||
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
|
||||
$tableBody = @{
|
||||
LatestAgentV2Version = $agentVersion
|
||||
LatestAgentV2Sha256 = $sha256
|
||||
LatestAgentV2ExeSha256 = $exeSha256
|
||||
}
|
||||
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{
|
||||
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers ($authHeaders + @{
|
||||
"Content-Type" = "application/json"
|
||||
"Accept" = "application/json;odata=nometadata"
|
||||
"If-Match" = "*"
|
||||
} -Body ($tableBody | ConvertTo-Json)
|
||||
}) -Body ($tableBody | ConvertTo-Json)
|
||||
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256"
|
||||
}
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
# Verify: gh attestation verify <zip> --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (ZIP)
|
||||
# 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@v4
|
||||
with:
|
||||
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
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@v7
|
||||
@@ -356,7 +509,7 @@ jobs:
|
||||
- **Published as stable:** ${{ inputs.publish_as_stable }}
|
||||
|
||||
### Provenance
|
||||
This ZIP carries a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
The executables are Authenticode-signed and timestamped. 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${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
```
|
||||
|
||||
+119
-12
@@ -1,11 +1,11 @@
|
||||
name: Build & Publish Bootstrap MSI
|
||||
|
||||
# Thin MDM LOB delivery vehicle for WDP (Autopilot v2) — see
|
||||
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI carries no bootstrap
|
||||
# logic (only the runner script that downloads the CURRENT server-hosted
|
||||
# Install-AutopilotMonitor.ps1), so it is rebuilt rarely: only when the runner script
|
||||
# or the WiX package changes. Version lives in Package.wxs — bump it there before
|
||||
# dispatching so Intune LOB update detection sees a new ProductVersion.
|
||||
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI is packaging only: it
|
||||
# carries the SAME stage-1 loader that is assigned directly in Intune, so it is rebuilt
|
||||
# rarely — only when that loader or the WiX package changes. Version lives in Package.wxs
|
||||
# — bump it there before dispatching so Intune LOB update detection sees a new
|
||||
# ProductVersion.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -23,8 +23,15 @@ permissions:
|
||||
|
||||
env:
|
||||
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
|
||||
RUNNER_SCRIPT: src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1
|
||||
RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1
|
||||
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
|
||||
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify steps.
|
||||
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 }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -35,8 +42,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# The runner executes as SYSTEM on every enrolling device — a parse error or
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
|
||||
# stands on its own because it can be dispatched at any time.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -62,9 +71,75 @@ jobs:
|
||||
Write-Host "MSI version (from Package.wxs): $version"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
# Every published MSI is signed; a missing variable stops the run before the build.
|
||||
- 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 published MSI must be Authenticode-signed."
|
||||
}
|
||||
}
|
||||
|
||||
# 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. Both federated credentials
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
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
|
||||
|
||||
# The blob upload below is authorized by this federated identity, not by a SAS.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Install WiX toolset
|
||||
run: dotnet tool install --global wix --version "5.*"
|
||||
|
||||
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# BEFORE the build: wix embeds the file as it finds it, so a loader signed afterwards
|
||||
# would leave the copy inside the MSI unsigned. Devices under an AllSigned policy need
|
||||
# the embedded loader to carry a signature of its own.
|
||||
- name: Sign bootstrap loader (Authenticode)
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
& 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 bootstrap runner' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
$env:RUNNER_SCRIPT
|
||||
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
|
||||
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -75,16 +150,46 @@ jobs:
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
$sha256 = (Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
Write-Host "SHA-256: $sha256"
|
||||
|
||||
# Signing rewrites the MSI, so it runs before the hash and the upload. A missing
|
||||
# timestamp fails the run: without it the signature dies with the certificate.
|
||||
- name: Sign MSI (Authenticode)
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
& 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 Bootstrap' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
$env:STABLE_MSI
|
||||
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
|
||||
|
||||
# The MSI and the loader it carries -- verified here, before anything is published.
|
||||
foreach ($file in @($env:STABLE_MSI, $env:RUNNER_SCRIPT)) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $file
|
||||
if ($sig.Status -ne 'Valid') { throw "Signature on $file is '$($sig.Status)', expected 'Valid'." }
|
||||
if (-not $sig.TimeStamperCertificate) { throw "Signature on $file carries no timestamp." }
|
||||
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "$file was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
Write-Host "$file : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
}
|
||||
|
||||
Write-Host "SHA-256: $((Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant())"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
|
||||
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
|
||||
@@ -102,7 +207,7 @@ jobs:
|
||||
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
|
||||
$md5.Dispose()
|
||||
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1KB)) KB)..."
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName`?$sasToken" -Method Put -Headers $headers -Body $bytes
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $headers) -Body $bytes
|
||||
Write-Host " uploaded $BlobName"
|
||||
}
|
||||
|
||||
@@ -125,9 +230,11 @@ jobs:
|
||||
}
|
||||
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
|
||||
|
||||
# Sigstore keyless provenance, same as the agent ZIP. Verify:
|
||||
# Sigstore keyless provenance, same as the agent ZIP; covers the signed bytes. Skipped
|
||||
# on runs that publish nothing -- no log entry for bytes nobody receives. Verify:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
if: inputs.upload_blob
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt"
|
||||
|
||||
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
|
||||
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -115,9 +115,9 @@ jobs:
|
||||
runId = '${{ github.run_id }}'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json?$sas"
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/backend.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt"
|
||||
|
||||
$pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
|
||||
$prefix = ($pkgVersion -split '\.')[0..1] -join '.'
|
||||
@@ -144,9 +144,9 @@ jobs:
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json?$sas"
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/mcp.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -40,6 +40,15 @@ jobs:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
|
||||
# Only the version manifest at the very end needs Azure; logging in here rather than
|
||||
# next to that step keeps a credential problem from surfacing after a live deploy.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# The customer docs repo is the single source of the "What's new" panel: the prebuild
|
||||
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
|
||||
# public/whats-new.json and dates every bullet via git blame — hence the full history.
|
||||
@@ -169,9 +178,9 @@ jobs:
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas"
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/web.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -26,6 +26,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
|
||||
# Shared with build-agent.yml: a script publish and an agent release both touch
|
||||
# version.json, and the bootstrapVersion read-modify-write must not interleave with the
|
||||
@@ -34,6 +35,16 @@ concurrency:
|
||||
group: agent-blob-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
# Authenticode for the customer scripts. Same identity and same repository variables as
|
||||
# build-agent.yml -- the loader on every device pins this publisher, so the two must not
|
||||
# drift apart.
|
||||
env:
|
||||
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 }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
@@ -45,12 +56,79 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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. Published customer scripts must be signed."
|
||||
}
|
||||
}
|
||||
|
||||
# First, and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account and the storage login below has to be the one that stays. See D-223 --
|
||||
# both federated credentials are bound to the ref, so this job must never carry a
|
||||
# GitHub `environment:`.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
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
|
||||
|
||||
# Authorizes the eu blob writes and the AdminConfiguration merge. The gates job above
|
||||
# runs the same publisher with -DryRun and deliberately without a login -- a dry run
|
||||
# must never need Azure credentials.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Version pinned so a new or compromised NuGet release cannot flow into signing
|
||||
# unreviewed; roll-forward because the tool targets an older runtime than the SDK
|
||||
# preinstalled on the runner.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
- name: Publish bootstrap + customer scripts
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}' `
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
|
||||
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
|
||||
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT `
|
||||
-SignedOutputDir "$env:RUNNER_TEMP/signed-scripts" `
|
||||
-DryRun:$dryRun
|
||||
|
||||
# The exact bytes that were (or, on a dry run, would have been) uploaded. A dry-run
|
||||
# dispatch is therefore the way to inspect a signed script locally before it reaches
|
||||
# a single customer. Kept on failure too: a failed alias verification is precisely
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
if-no-files-found: ignore
|
||||
|
||||
+1
-1
Submodule infra updated: 2c2687b6ab...55a8eaf929
+1
-1
Submodule internal updated: 41d3f15126...8204fc68af
@@ -0,0 +1,147 @@
|
||||
# Pester tests (Pester 5+) for the stage-1 loader Start-AutopilotMonitor.ps1.
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the loader from
|
||||
# executing, so loading it here is side-effect free. Network and signature checks
|
||||
# are mocked -- what is verified is the DECISION: what does the loader accept, what
|
||||
# does it refuse, and how often does it retry.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1')
|
||||
|
||||
function New-FakeSignature {
|
||||
param([string]$Status, [string]$Subject)
|
||||
$cert = if ($Subject) {
|
||||
[pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' }
|
||||
} else { $null }
|
||||
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-BootstrapSignature' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
}
|
||||
|
||||
It 'accepts a valid signature from the expected publisher' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
||||
}
|
||||
|
||||
# An EV subject is a line of street address and registration numbers; the log gets the CN.
|
||||
It 'logs the signer CN, not the whole subject' {
|
||||
Mock Get-AuthenticodeSignature {
|
||||
New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach am Main, C=DE, SERIALNUMBER=HRB 12381'
|
||||
}
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
||||
Should -Invoke Write-Log -ParameterFilter { $Message -eq 'Signature status: Valid. Signer: glueckkanja AG' }
|
||||
}
|
||||
|
||||
It 'refuses an unsigned script' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a tampered script (hash mismatch)' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a signature whose chain does not validate' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
# The whole point of the pinned publisher: a validly signed script from someone else
|
||||
# is exactly what an attacker with any code-signing certificate can produce.
|
||||
It 'refuses a valid signature from a different publisher' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses when the signature check itself fails' {
|
||||
Mock Get-AuthenticodeSignature { throw 'file not found' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-VerifiedBootstrapScript' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Start-Sleep { }
|
||||
Mock Invoke-WebRequest { }
|
||||
}
|
||||
|
||||
It 'returns after the first attempt when the download verifies' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'gives up after three attempts when verification keeps failing' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
||||
}
|
||||
|
||||
# A truncated or proxy-mangled body fails verification, so the retry has to cover the
|
||||
# download AND the check, not just the transport.
|
||||
It 'retries a failed verification and accepts a later attempt' {
|
||||
$script:calls = 0
|
||||
Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'retries a transport failure' {
|
||||
$script:downloads = 0
|
||||
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' } }
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'never executes anything when no attempt verifies' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish contract' {
|
||||
BeforeAll {
|
||||
$script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -Raw
|
||||
}
|
||||
|
||||
# Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact
|
||||
# literal and fails the publish when it is gone. Catching that here means the PR fails
|
||||
# instead of the publish.
|
||||
It 'carries the dev-render anchor the publisher substitutes' {
|
||||
$anchor = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
|
||||
$script:loaderText.Contains($anchor) | Should -BeTrue
|
||||
}
|
||||
|
||||
# The publisher parses this to write version.json.loaderVersion and to run the
|
||||
# version-bump guard.
|
||||
It 'declares a parsable ScriptVersion' {
|
||||
$script:loaderText -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'pins the expected publisher by subject, not by thumbprint' {
|
||||
$script:loaderText -match '\$ExpectedPublisher\s*=\s*"\*O=' | Should -BeTrue
|
||||
}
|
||||
|
||||
# The bootstrap MSI installs this same file and runs it with -LogFileName so the delivery
|
||||
# channel stays visible in a diagnostics package. Renaming the parameter on either side
|
||||
# would leave the MSI channel silently logging under the wrong name.
|
||||
It 'keeps the -LogFileName contract the bootstrap MSI relies on' {
|
||||
$script:loaderText -match '\[string\]\$LogFileName' | Should -BeTrue
|
||||
|
||||
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
||||
$wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
|
||||
|
||||
.DESCRIPTION
|
||||
This is the file an administrator assigns in Intune, and the same file the bootstrap MSI
|
||||
installs and runs -- one implementation for both delivery channels; the MSI is only the
|
||||
packaging. It carries no enrollment logic at all: it downloads the published
|
||||
Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it
|
||||
in this process.
|
||||
|
||||
Why two stages: the installer holds every guard, relax rule and download path, and those
|
||||
change. Whoever assigns the installer directly has to replace their Intune copy each
|
||||
time; whoever assigns this loader never does, because the part that changes lives on the
|
||||
server. Assigning the installer directly stays supported and unchanged.
|
||||
|
||||
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
|
||||
it does the least it can: download, verify, invoke, log.
|
||||
|
||||
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
|
||||
fetched from the same host as the script itself, so it could not survive a compromise of
|
||||
that host, while the expected publisher below travels with this file. It pins the
|
||||
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
|
||||
loader that is already deployed on devices.
|
||||
|
||||
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
|
||||
cannot reach the download endpoint is a device without monitoring, never a failed
|
||||
enrollment step. Verification failures are the same: nothing runs, and the reason is in
|
||||
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
|
||||
|
||||
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
.PARAMETER BootstrapUrl
|
||||
Installer URL. Defaults to the published stable installer; the -Dev variant of this
|
||||
loader is rendered from that default at publish time.
|
||||
|
||||
.PARAMETER LogFileName
|
||||
Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the
|
||||
delivery channel stays visible in a diagnostics package; the diag tooling keys on these
|
||||
names, so do not rename them.
|
||||
|
||||
.PARAMETER BootstrapArguments
|
||||
Everything else is passed through to the installer unchanged, so any parameter the
|
||||
installer accepts can be set here without this file knowing about it.
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
|
||||
|
||||
.NOTES
|
||||
Author : Oliver Kieselbach (autopilotmonitor.com)
|
||||
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
|
||||
logged only when the publisher is why a script was rejected.
|
||||
2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this
|
||||
logic; -LogFileName keeps the channel visible in diagnostics.
|
||||
2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$LogFileName = "bootstrap-loader.log",
|
||||
|
||||
[Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$BootstrapArguments
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "1.2"
|
||||
|
||||
# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject,
|
||||
# NOT a thumbprint: certificates are renewed, deployed loaders are not.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$LoaderLogPath = "$LoaderBasePath\Logs"
|
||||
$LogFile = "$LoaderLogPath\$LogFileName"
|
||||
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
|
||||
|
||||
$DownloadAttempts = 3
|
||||
$RetryDelaySeconds = 10
|
||||
|
||||
# Write-Host as well as the file: the Intune script output is what an administrator sees in
|
||||
# the portal, the file is what a support case reads.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# True only when the file is signed, the signature validates against the machine's trust
|
||||
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
|
||||
# unverified script.
|
||||
function Test-BootstrapSignature {
|
||||
param([string]$Path)
|
||||
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Log "Signature check failed to run: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
Write-Log "Signature check returned nothing."
|
||||
return $false
|
||||
}
|
||||
|
||||
# The full subject of an EV certificate is a line of street address and registration
|
||||
# numbers. The CN answers the only question the log has to answer; the full subject is
|
||||
# logged when it is the reason for a rejection.
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
|
||||
$signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject }
|
||||
Write-Log "Signature status: $($sig.Status). Signer: $signer"
|
||||
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Log "REJECTED: expected signature status 'Valid'."
|
||||
return $false
|
||||
}
|
||||
if ($subject -notlike $ExpectedPublisher) {
|
||||
Write-Log "REJECTED: signer does not match the expected publisher. Full subject: $subject"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))."
|
||||
return $true
|
||||
}
|
||||
|
||||
# Downloads the installer and returns $true once a downloaded copy passes verification.
|
||||
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
|
||||
# fails the signature check, and that is worth another attempt.
|
||||
function Get-VerifiedBootstrapScript {
|
||||
param([string]$Url, [string]$Destination)
|
||||
|
||||
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
|
||||
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
|
||||
if (Test-BootstrapSignature -Path $Destination) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($attempt -lt $DownloadAttempts) {
|
||||
Start-Sleep -Seconds $RetryDelaySeconds
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
|
||||
# so the Pester suite can dot-source this file without bootstrapping anything.
|
||||
function Start-BootstrapLoader {
|
||||
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
|
||||
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $BootstrapPath"
|
||||
|
||||
# Same process, so the execution policy this loader runs under applies to stage 2 as
|
||||
# well and no second policy decision can strand the installer. Splatting an array of
|
||||
# remaining arguments keeps this file free of any knowledge about the installer's
|
||||
# parameters.
|
||||
$arguments = @()
|
||||
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
|
||||
|
||||
try {
|
||||
& $BootstrapPath @arguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
try {
|
||||
Start-BootstrapLoader
|
||||
}
|
||||
catch {
|
||||
# Nothing above should throw; if it does, it must not become a failed Intune script.
|
||||
Write-Log "Loader failed: $($_.Exception.Message)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
+10
-1
@@ -223,7 +223,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return true;
|
||||
}
|
||||
|
||||
private const int MaxScriptOutputLength = 2048;
|
||||
// Script stdout kept per event. Measured over 1,486 script outputs in the fleet
|
||||
// (2026-08-25..09-08): median 39, p90 644, max 1,501 chars -- the maximum being our own
|
||||
// one-stage bootstrap, and nothing ever hit the previous 2,048 cap. The two-stage
|
||||
// bootstrap then added a ~460-char loader preamble to that maximum and started
|
||||
// truncating the one output an operator actually reads. 8,192 leaves room for the
|
||||
// realistic worst case (download retries plus relax logging, ~2,700) and stays 4x below
|
||||
// the 32K-char Table Storage property limit that bounds the event downstream. Growth
|
||||
// driver is the number of log lines the bootstrap chain writes, so re-measure before
|
||||
// adding a chatty step rather than raising this again.
|
||||
private const int MaxScriptOutputLength = 8192;
|
||||
private const int MaxMultiLineBufferLines = 100;
|
||||
// Size cap for ONE entry — a single physical line (enforced by BoundedLineReader before
|
||||
// the line is materialized) and an assembled multiline entry alike. Any process able to
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI.
|
||||
#
|
||||
# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME
|
||||
# script/app batches during Autopilot Device Preparation). This runner downloads the
|
||||
# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so
|
||||
# every piece of real bootstrap logic (guards, relax rules, agent download) stays
|
||||
# server-updatable without re-shipping the MSI.
|
||||
#
|
||||
# Fail-soft by design: the runner always exits 0 so a transient network failure never
|
||||
# marks the LOB install as failed; diagnostics go to bootstrap-msi.log.
|
||||
# ASCII only - no special characters in this file.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor'
|
||||
$logDir = Join-Path $baseDir 'Logs'
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
$log = Join-Path $logDir 'bootstrap-msi.log'
|
||||
|
||||
function Write-Log([string]$message) {
|
||||
Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message)
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)."
|
||||
|
||||
if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') {
|
||||
Write-Log 'Deployed marker present - skipping, another channel already installed the agent.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
|
||||
$dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1'
|
||||
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest
|
||||
break
|
||||
} catch {
|
||||
Write-Log "Download attempt $attempt failed: $_"
|
||||
if ($attempt -eq 3) { throw }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Bootstrap script downloaded to $dest - executing."
|
||||
# PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise
|
||||
# written via Add-Content (ANSI). Route through Add-Content for one encoding.
|
||||
& $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log
|
||||
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String))
|
||||
exit 0
|
||||
}
|
||||
@@ -4,16 +4,19 @@
|
||||
LOB MSIs are installed by the OMA-DM stack (EnterpriseDesktopAppManagement CSP) during
|
||||
the first MDM sync, i.e. BEFORE/parallel to the Device Preparation SLDM app batch -
|
||||
earlier than any IME-delivered script (Batch 2) or Win32 app can run. The MSI itself
|
||||
carries no bootstrap logic: it installs one runner script and executes it once. The
|
||||
runner downloads the CURRENT server-hosted Install-AutopilotMonitor.ps1, keeping all
|
||||
real logic updatable without re-shipping the MSI.
|
||||
carries no bootstrap logic and no downloader of its own: it installs the SAME stage-1
|
||||
loader that is assigned directly in Intune (scripts/Bootstrap/Start-AutopilotMonitor.ps1)
|
||||
and runs it once. The loader downloads the current server-hosted
|
||||
Install-AutopilotMonitor.ps1 and verifies its publisher before executing it, so all real
|
||||
logic stays updatable without re-shipping the MSI, and both delivery channels share one
|
||||
implementation.
|
||||
|
||||
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Autopilot Monitor Bootstrap"
|
||||
Manufacturer="Autopilot Monitor"
|
||||
Version="1.0.2"
|
||||
Version="1.1.0"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
@@ -28,8 +31,13 @@
|
||||
</StandardDirectory>
|
||||
|
||||
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
|
||||
<Component Id="RunnerScript" Guid="8c8e8962-719f-458b-b291-dd99d3f1dc30">
|
||||
<File Id="RunnerPs1" Source="Invoke-BootstrapDownload.ps1" KeyPath="yes" />
|
||||
<!-- Source is relative to the build working directory (the project dir), not to this
|
||||
file. New component GUID because the key path changed. -->
|
||||
<Component Id="RunnerScript" Guid="A972F561-01B5-400A-8C2A-47D12BA97DCA">
|
||||
<File Id="RunnerPs1"
|
||||
Source="..\..\..\scripts\Bootstrap\Start-AutopilotMonitor.ps1"
|
||||
Name="Start-AutopilotMonitor.ps1"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
@@ -38,11 +46,12 @@
|
||||
</Feature>
|
||||
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
|
||||
the LOB install green even if the runner hits a transient error (the runner is
|
||||
additionally fail-soft itself); diagnostics live in bootstrap-msi.log. -->
|
||||
the LOB install green even if the loader hits a transient error (the loader is
|
||||
additionally fail-soft itself). -LogFileName keeps this channel distinguishable in
|
||||
a diagnostics package; the diag tooling keys on that name. -->
|
||||
<CustomAction Id="RunBootstrap"
|
||||
Directory="INSTALLFOLDER"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Invoke-BootstrapDownload.ps1""
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Start-AutopilotMonitor.ps1" -LogFileName bootstrap-msi.log"
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
Return="ignore" />
|
||||
|
||||
@@ -9,7 +9,7 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the schemaVersion-2 catalog contract (generated by scripts/error-codes/generate.mjs):
|
||||
/// Pins the schemaVersion-2 catalog contract (generated by tools/error-codes/generate.mjs):
|
||||
/// key/source/category vocabulary, the HRESULT_FROM_WIN32 derivation, the symbol and
|
||||
/// enforcement-state indexes, and the handful of entries product code and rules rely on.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.WhatsNew;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.WhatsNew;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What's new → channel digest: feed parsing, watermark semantics (baseline on first run, diff by
|
||||
/// entry key, claim-then-send with ETag CAS), per-channel opt-in routing, and the digest shape.
|
||||
/// </summary>
|
||||
public class WhatsNewNotificationServiceTests
|
||||
{
|
||||
// ── Fakes ─────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class InMemoryStateRepository : IWhatsNewNotificationStateRepository
|
||||
{
|
||||
private WhatsNewNotificationState? _row;
|
||||
private long _version = 0;
|
||||
public int ConflictsToInject { get; set; }
|
||||
public int Writes { get; private set; }
|
||||
|
||||
public WhatsNewNotificationState? Current => _row;
|
||||
|
||||
public void Seed(params string[] knownKeys)
|
||||
{
|
||||
_row = new WhatsNewNotificationState { KnownEntryKeys = new HashSet<string>(knownKeys, StringComparer.Ordinal) };
|
||||
_version++;
|
||||
}
|
||||
|
||||
public Task<(WhatsNewNotificationState? State, string? ETag)> GetWithETagAsync()
|
||||
=> Task.FromResult(_row == null ? ((WhatsNewNotificationState?)null, (string?)null) : (Clone(_row), _version.ToString()));
|
||||
|
||||
public Task<bool> TryUpsertAsync(WhatsNewNotificationState state, string? ifMatchETag)
|
||||
{
|
||||
if (ConflictsToInject > 0)
|
||||
{
|
||||
ConflictsToInject--;
|
||||
_version++;
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
if (ifMatchETag is null && _row != null) return Task.FromResult(false);
|
||||
if (ifMatchETag is not null && ifMatchETag != _version.ToString()) return Task.FromResult(false);
|
||||
|
||||
_row = Clone(state);
|
||||
_version++;
|
||||
Writes++;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private static WhatsNewNotificationState Clone(WhatsNewNotificationState s) => new()
|
||||
{
|
||||
KnownEntryKeys = new HashSet<string>(s.KnownEntryKeys, StringComparer.Ordinal),
|
||||
LastDocsCommit = s.LastDocsCommit,
|
||||
LastRunUtc = s.LastRunUtc,
|
||||
LastNotifiedUtc = s.LastNotifiedUtc,
|
||||
LastNotifiedEntryCount = s.LastNotifiedEntryCount,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public InMemoryStateRepository StateRepo { get; } = new();
|
||||
public Mock<IWhatsNewFeedClient> Feed { get; } = new();
|
||||
public Mock<IConfigRepository> ConfigRepo { get; } = new();
|
||||
public Mock<NotificationChannelDispatcher> Dispatcher { get; }
|
||||
public List<(List<NotificationChannel> Channels, NotificationAlert Alert)> Sent { get; } = new();
|
||||
public WhatsNewNotificationService Service { get; }
|
||||
public DateTime Now { get; } = new(2026, 9, 8, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var telegram = new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance);
|
||||
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, telegram);
|
||||
Dispatcher
|
||||
.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>()))
|
||||
.Returns<IEnumerable<NotificationChannel>, NotificationAlert>((channels, alert) =>
|
||||
{
|
||||
Sent.Add((channels.ToList(), alert));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
ConfigRepo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List<TenantConfiguration>());
|
||||
|
||||
Service = new WhatsNewNotificationService(
|
||||
Feed.Object,
|
||||
StateRepo,
|
||||
ConfigRepo.Object,
|
||||
Dispatcher.Object,
|
||||
new TelemetryClient(new TelemetryConfiguration()),
|
||||
NullLogger<WhatsNewNotificationService>.Instance,
|
||||
() => Now);
|
||||
}
|
||||
|
||||
public void SetFeed(WhatsNewFeed? feed)
|
||||
=> Feed.Setup(f => f.GetAsync(It.IsAny<CancellationToken>())).ReturnsAsync(feed);
|
||||
|
||||
public void SetTenants(params TenantConfiguration[] tenants)
|
||||
=> ConfigRepo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(tenants.ToList());
|
||||
}
|
||||
|
||||
private static WhatsNewFeedEntry Entry(string channel, string id, string? title = null, string body = "Body text.", int daysAgo = 0)
|
||||
=> new()
|
||||
{
|
||||
Channel = channel,
|
||||
Id = id,
|
||||
AddedUtc = new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc).AddDays(-daysAgo),
|
||||
Period = "September 2026",
|
||||
Title = title,
|
||||
Body = body,
|
||||
Link = null,
|
||||
};
|
||||
|
||||
private static WhatsNewFeed Feed(params WhatsNewFeedEntry[] entries) => new()
|
||||
{
|
||||
DocsCommit = "abc1234",
|
||||
Entries = entries,
|
||||
DocsUrls = new Dictionary<string, string>
|
||||
{
|
||||
[WhatsNewFeed.PlatformChannel] = Constants.DocsBaseUrl + "/changelog/platform-changelog",
|
||||
[WhatsNewFeed.AgentChannel] = Constants.DocsBaseUrl + "/changelog/agent-changelog",
|
||||
},
|
||||
};
|
||||
|
||||
private static TenantConfiguration Tenant(string id, params NotificationChannel[] channels) => new()
|
||||
{
|
||||
TenantId = id,
|
||||
NotificationChannelsJson = NotificationChannel.SerializeList(channels),
|
||||
};
|
||||
|
||||
private static NotificationChannel Channel(string id, bool whatsNew, bool enabled = true, string url = "https://hooks.example/x")
|
||||
=> new() { Id = id, Name = id, ProviderType = 20, Url = url, Enabled = enabled, NotifyOnWhatsNew = whatsNew, NotifyOnSuccess = true };
|
||||
|
||||
// ── Feed parsing ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidPayload_YieldsEntriesForBothChannels()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedUtc": "2026-09-08T10:00:00.000Z",
|
||||
"docsCommit": "deadbee",
|
||||
"channels": {
|
||||
"platform": { "docsUrl": "https://docs.autopilotmonitor.com/changelog/platform-changelog", "entries": [
|
||||
{ "id": "0a1b2c3d", "addedUtc": "2026-09-07T09:00:00.000Z", "period": "September 2026", "title": "**Bold lead**", "body": "Body with [link](https://docs.autopilotmonitor.com/x).", "link": "https://docs.autopilotmonitor.com/x" }
|
||||
] },
|
||||
"agent": { "docsUrl": "https://docs.autopilotmonitor.com/changelog/agent-changelog", "entries": [
|
||||
{ "id": "ffffffff", "addedUtc": "2026-09-01T09:00:00.000Z", "period": "September 2026", "title": null, "body": "Agent bullet.", "link": null }
|
||||
] }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var feed = WhatsNewFeed.Parse(json);
|
||||
|
||||
Assert.NotNull(feed);
|
||||
Assert.Equal("deadbee", feed!.DocsCommit);
|
||||
Assert.Equal(2, feed.Entries.Count);
|
||||
Assert.Equal("platform:0a1b2c3d", feed.Entries[0].Key);
|
||||
Assert.Equal("agent:ffffffff", feed.Entries[1].Key);
|
||||
Assert.Equal(new DateTime(2026, 9, 7, 9, 0, 0, DateTimeKind.Utc), feed.Entries[0].AddedUtc);
|
||||
Assert.Equal("https://docs.autopilotmonitor.com/changelog/agent-changelog", feed.DocsUrls[WhatsNewFeed.AgentChannel]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_OffShape_YieldsNull()
|
||||
{
|
||||
Assert.Null(WhatsNewFeed.Parse(null));
|
||||
Assert.Null(WhatsNewFeed.Parse(""));
|
||||
Assert.Null(WhatsNewFeed.Parse("not json"));
|
||||
Assert.Null(WhatsNewFeed.Parse("""{"schemaVersion":2,"channels":{"platform":{"entries":[]},"agent":{"entries":[]}}}"""));
|
||||
// Missing channel
|
||||
Assert.Null(WhatsNewFeed.Parse("""{"schemaVersion":1,"channels":{"platform":{"entries":[]}}}"""));
|
||||
// Entry without id
|
||||
Assert.Null(WhatsNewFeed.Parse("""{"schemaVersion":1,"channels":{"platform":{"entries":[{"addedUtc":"2026-09-07T09:00:00Z","body":"x"}]},"agent":{"entries":[]}}}"""));
|
||||
}
|
||||
|
||||
// ── Run semantics ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Run_FeedUnavailable_DoesNotTouchStateOrSend()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetFeed(null);
|
||||
h.StateRepo.Seed("platform:old");
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.True(result.FeedUnavailable);
|
||||
Assert.Equal(0, h.StateRepo.Writes);
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_FirstRun_BaselinesWithoutSending()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetFeed(Feed(Entry("platform", "a"), Entry("agent", "b")));
|
||||
h.SetTenants(Tenant("t1", Channel("c1", whatsNew: true)));
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.True(result.Baselined);
|
||||
Assert.Empty(h.Sent);
|
||||
Assert.NotNull(h.StateRepo.Current);
|
||||
Assert.Equal(new[] { "agent:b", "platform:a" }, h.StateRepo.Current!.KnownEntryKeys.OrderBy(k => k));
|
||||
Assert.Equal("abc1234", h.StateRepo.Current.LastDocsCommit);
|
||||
Assert.Null(h.StateRepo.Current.LastNotifiedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_EmptyKnownSet_IsTreatedAsBaseline()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed(); // row exists, no keys
|
||||
h.SetFeed(Feed(Entry("platform", "a")));
|
||||
h.SetTenants(Tenant("t1", Channel("c1", whatsNew: true)));
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.True(result.Baselined);
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NothingNew_NoSendAndNoWriteWhenUnchanged()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed("platform:a", "agent:b");
|
||||
h.SetFeed(Feed(Entry("platform", "a"), Entry("agent", "b")));
|
||||
h.SetTenants(Tenant("t1", Channel("c1", whatsNew: true)));
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.Equal(0, result.NewEntries);
|
||||
Assert.Empty(h.Sent);
|
||||
Assert.Equal(0, h.StateRepo.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_EntriesRolledOut_UpdatesWatermarkWithoutSending()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed("platform:a", "platform:old");
|
||||
h.SetFeed(Feed(Entry("platform", "a")));
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.Equal(0, result.NewEntries);
|
||||
Assert.Empty(h.Sent);
|
||||
Assert.Equal(1, h.StateRepo.Writes);
|
||||
Assert.Equal(new[] { "platform:a" }, h.StateRepo.Current!.KnownEntryKeys.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NewEntries_SendsOneDigestPerTenantToOptedInChannelsOnly()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed("platform:a");
|
||||
h.SetFeed(Feed(Entry("platform", "a"), Entry("platform", "b", title: "**New** thing"), Entry("agent", "c")));
|
||||
h.SetTenants(
|
||||
Tenant("opted-in", Channel("yes", whatsNew: true), Channel("no", whatsNew: false), Channel("disabled", whatsNew: true, enabled: false)),
|
||||
Tenant("not-opted-in", Channel("no", whatsNew: false)),
|
||||
Tenant("no-channels"));
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.Equal(2, result.NewEntries);
|
||||
Assert.Equal(1, result.TenantsNotified);
|
||||
Assert.Equal(1, result.ChannelsNotified);
|
||||
|
||||
var (channels, alert) = Assert.Single(h.Sent);
|
||||
Assert.Equal("yes", Assert.Single(channels).Id);
|
||||
Assert.Equal("whats_new", alert.EventType);
|
||||
Assert.Equal(2, alert.Sections.Count);
|
||||
Assert.Contains("2 new updates", alert.Summary);
|
||||
Assert.Contains("1 platform, 1 agent", alert.Summary);
|
||||
|
||||
// Watermark advanced to the full live set and marked as notified.
|
||||
var state = h.StateRepo.Current!;
|
||||
Assert.Equal(new[] { "agent:c", "platform:a", "platform:b" }, state.KnownEntryKeys.OrderBy(k => k));
|
||||
Assert.Equal(h.Now, state.LastNotifiedUtc);
|
||||
Assert.Equal(2, state.LastNotifiedEntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_SkipsDisabledTenants()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed("platform:a");
|
||||
h.SetFeed(Feed(Entry("platform", "a"), Entry("platform", "b")));
|
||||
var disabled = Tenant("off", Channel("yes", whatsNew: true));
|
||||
disabled.Disabled = true;
|
||||
h.SetTenants(disabled);
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.Equal(1, result.NewEntries);
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_LostRace_DoesNotSend()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed("platform:a");
|
||||
h.StateRepo.ConflictsToInject = 1;
|
||||
h.SetFeed(Feed(Entry("platform", "a"), Entry("platform", "b")));
|
||||
h.SetTenants(Tenant("t1", Channel("c1", whatsNew: true)));
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.True(result.LostRace);
|
||||
Assert.Empty(h.Sent);
|
||||
h.ConfigRepo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_SecondRunAfterAnnouncement_IsQuiet()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed("platform:a");
|
||||
h.SetFeed(Feed(Entry("platform", "a"), Entry("platform", "b")));
|
||||
h.SetTenants(Tenant("t1", Channel("c1", whatsNew: true)));
|
||||
|
||||
await h.Service.RunAsync();
|
||||
var second = await h.Service.RunAsync();
|
||||
|
||||
Assert.Equal(0, second.NewEntries);
|
||||
Assert.Single(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_LegacySingleWebhookTenant_NeverReceivesWhatsNew()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.StateRepo.Seed("platform:a");
|
||||
h.SetFeed(Feed(Entry("platform", "a"), Entry("platform", "b")));
|
||||
h.SetTenants(new TenantConfiguration
|
||||
{
|
||||
TenantId = "legacy",
|
||||
WebhookUrl = "https://hooks.example/legacy",
|
||||
WebhookProviderType = 2,
|
||||
WebhookNotifyOnSuccess = true,
|
||||
});
|
||||
|
||||
var result = await h.Service.RunAsync();
|
||||
|
||||
Assert.Equal(1, result.NewEntries);
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
// ── Digest shape ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildWhatsNewAlert_NewestFirst_PlainText_Actions()
|
||||
{
|
||||
var entries = new List<WhatsNewFeedEntry>
|
||||
{
|
||||
Entry("agent", "old", body: "Older agent `fix` here.", daysAgo: 5),
|
||||
Entry("platform", "new", title: "**Rules editor** revamp", body: "Read the [guide](https://docs.autopilotmonitor.com/g) for _details_."),
|
||||
};
|
||||
var docs = new Dictionary<string, string>
|
||||
{
|
||||
[WhatsNewFeed.PlatformChannel] = Constants.DocsBaseUrl + "/changelog/platform-changelog",
|
||||
[WhatsNewFeed.AgentChannel] = Constants.DocsBaseUrl + "/changelog/agent-changelog",
|
||||
};
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildWhatsNewAlert(entries, docs, WhatsNewNotificationService.PortalWhatsNewUrl("platform"));
|
||||
|
||||
Assert.Equal(2, alert.Sections.Count);
|
||||
Assert.StartsWith("Platform \u00b7 Sep 8, 2026 \u2014 Rules editor revamp", alert.Sections[0].Title);
|
||||
Assert.Equal("Read the guide for details.", alert.Sections[0].Text);
|
||||
Assert.StartsWith("Agent \u00b7 Sep 3, 2026", alert.Sections[1].Title);
|
||||
Assert.Equal("Older agent fix here.", alert.Sections[1].Text);
|
||||
|
||||
Assert.Equal(2, alert.Actions.Count);
|
||||
Assert.Equal(Constants.PortalBaseUrl + "/dashboard?whats-new=platform", alert.Actions[0].Url);
|
||||
// Tie (1 platform, 1 agent) → platform changelog.
|
||||
Assert.Equal(Constants.DocsBaseUrl + "/changelog/platform-changelog", alert.Actions[1].Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWhatsNewAlert_CapsSectionsAndFoldsRemainder()
|
||||
{
|
||||
var entries = Enumerable.Range(0, NotificationAlertBuilder.WhatsNewMaxSections + 3)
|
||||
.Select(i => Entry("agent", $"id{i}", body: $"Bullet {i}", daysAgo: i))
|
||||
.ToList();
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildWhatsNewAlert(entries, null, WhatsNewNotificationService.PortalWhatsNewUrl("agent"));
|
||||
|
||||
Assert.Equal(NotificationAlertBuilder.WhatsNewMaxSections + 1, alert.Sections.Count);
|
||||
Assert.Contains("+ 3 more updates", alert.Sections[^1].Text);
|
||||
Assert.Single(alert.Actions); // no docs URLs → only the portal button
|
||||
Assert.Contains($"{entries.Count} new updates", alert.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWhatsNewAlert_TelegramText_CarriesSectionsAndLinks()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildWhatsNewAlert(
|
||||
new List<WhatsNewFeedEntry> { Entry("platform", "p", title: "**Lead**", body: "Body.") },
|
||||
null,
|
||||
WhatsNewNotificationService.PortalWhatsNewUrl("platform"));
|
||||
|
||||
var text = TelegramNotificationService.RenderAlertText(alert);
|
||||
|
||||
Assert.Contains("What's new in Autopilot Monitor", text);
|
||||
Assert.Contains("Platform \u00b7 Sep 8, 2026 \u2014 Lead", text);
|
||||
Assert.Contains("Body.", text);
|
||||
Assert.Contains("Open What's new: " + Constants.PortalBaseUrl + "/dashboard?whats-new=platform", text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("**Bold** text", "Bold text")]
|
||||
[InlineData("See [the docs](https://docs.autopilotmonitor.com/x) now", "See the docs now")]
|
||||
[InlineData("Run `agent.exe --verbose`", "Run agent.exe --verbose")]
|
||||
[InlineData("_emphasis_ and *more*", "emphasis and more")]
|
||||
[InlineData("snake_case_name stays", "snake_case_name stays")]
|
||||
[InlineData(" spaced out ", "spaced out")]
|
||||
[InlineData(null, "")]
|
||||
public void StripInlineMarkdown_Flattens(string? input, string expected)
|
||||
=> Assert.Equal(expected, NotificationAlertBuilder.StripInlineMarkdown(input));
|
||||
|
||||
// ── Channel model ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NotifyOnWhatsNew_RoundTripsThroughJson_AndDefaultsOff()
|
||||
{
|
||||
var json = NotificationChannel.SerializeList(new[] { Channel("c", whatsNew: true) });
|
||||
Assert.Contains("\"notifyOnWhatsNew\":true", json);
|
||||
Assert.True(NotificationChannel.ParseList(json).Single().NotifyOnWhatsNew);
|
||||
|
||||
var legacyShape = "[{\"id\":\"x\",\"name\":\"x\",\"providerType\":20,\"url\":\"https://hooks.example/x\",\"enabled\":true}]";
|
||||
Assert.False(NotificationChannel.ParseList(legacyShape).Single().NotifyOnWhatsNew);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
services.AddSingleton<INotificationRepository, TableNotificationRepository>();
|
||||
services.AddSingleton<ITenantNotificationRepository, TableTenantNotificationRepository>();
|
||||
services.AddSingleton<ISlaTenantStatusRepository, TableSlaTenantStatusRepository>();
|
||||
// What's new notifier watermark (single platform row in the AdminConfiguration table).
|
||||
services.AddSingleton<IWhatsNewNotificationStateRepository, TableWhatsNewNotificationStateRepository>();
|
||||
services.AddSingleton<IHardwareRejectionNotificationTracker, TableHardwareRejectionNotificationTracker>();
|
||||
services.AddSingleton<IDeviceSecurityRepository, TableDeviceSecurityRepository>();
|
||||
services.AddSingleton<IUserUsageRepository, TableUserUsageRepository>();
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.WhatsNew;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table Storage implementation of <see cref="IWhatsNewNotificationStateRepository"/>.
|
||||
/// Lives as a side row in the AdminConfiguration table (PartitionKey
|
||||
/// <see cref="PartitionKey"/>, RowKey <see cref="RowKey"/>) — platform-scoped state next to the
|
||||
/// table-schema sentinel, so no new table is needed. Deliberately NOT a field on the
|
||||
/// AdminConfiguration row itself: that row is round-tripped wholesale by the Global Admin
|
||||
/// settings UI, and a stale save there would roll the watermark back and re-announce entries.
|
||||
/// </summary>
|
||||
public class TableWhatsNewNotificationStateRepository : IWhatsNewNotificationStateRepository
|
||||
{
|
||||
internal const string PartitionKey = "WhatsNewNotifications";
|
||||
internal const string RowKey = "state";
|
||||
|
||||
private const string KnownEntryKeysProperty = "KnownEntryKeysJson";
|
||||
private const string LastDocsCommitProperty = "LastDocsCommit";
|
||||
private const string LastRunUtcProperty = "LastRunUtc";
|
||||
private const string LastNotifiedUtcProperty = "LastNotifiedUtc";
|
||||
private const string LastNotifiedEntryCountProperty = "LastNotifiedEntryCount";
|
||||
|
||||
private readonly TableClient _table;
|
||||
private readonly ILogger<TableWhatsNewNotificationStateRepository> _logger;
|
||||
|
||||
public TableWhatsNewNotificationStateRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableWhatsNewNotificationStateRepository> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_table = storage.GetTableClient(Constants.TableNames.AdminConfiguration);
|
||||
}
|
||||
|
||||
public async Task<(WhatsNewNotificationState? State, string? ETag)> GetWithETagAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _table.GetEntityIfExistsAsync<TableEntity>(PartitionKey, RowKey);
|
||||
if (!response.HasValue)
|
||||
return (null, null);
|
||||
|
||||
var entity = response.Value!;
|
||||
return (MapFromEntity(entity), entity.ETag.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load What's new notification state");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryUpsertAsync(WhatsNewNotificationState state, string? ifMatchETag)
|
||||
{
|
||||
if (state == null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var entity = MapToEntity(state);
|
||||
|
||||
if (ifMatchETag is null)
|
||||
{
|
||||
await _table.AddEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
await _table.UpdateEntityAsync(entity, new ETag(ifMatchETag), TableUpdateMode.Replace);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409 || ex.Status == 412)
|
||||
{
|
||||
// 409 EntityAlreadyExists (AddEntity) or 412 PreconditionFailed (ETag mismatch):
|
||||
// a parallel run committed first — the caller must not send.
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed conditional upsert of What's new notification state");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static TableEntity MapToEntity(WhatsNewNotificationState state)
|
||||
{
|
||||
var entity = new TableEntity(PartitionKey, RowKey)
|
||||
{
|
||||
[KnownEntryKeysProperty] = JsonSerializer.Serialize(state.KnownEntryKeys),
|
||||
[LastNotifiedEntryCountProperty] = state.LastNotifiedEntryCount,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(state.LastDocsCommit))
|
||||
entity[LastDocsCommitProperty] = state.LastDocsCommit;
|
||||
if (state.LastRunUtc.HasValue)
|
||||
entity[LastRunUtcProperty] = DateTime.SpecifyKind(state.LastRunUtc.Value, DateTimeKind.Utc);
|
||||
if (state.LastNotifiedUtc.HasValue)
|
||||
entity[LastNotifiedUtcProperty] = DateTime.SpecifyKind(state.LastNotifiedUtc.Value, DateTimeKind.Utc);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private WhatsNewNotificationState MapFromEntity(TableEntity entity)
|
||||
{
|
||||
var known = new HashSet<string>(StringComparer.Ordinal);
|
||||
var json = entity.GetString(KnownEntryKeysProperty);
|
||||
if (!string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<List<string>>(json!);
|
||||
if (parsed != null)
|
||||
{
|
||||
foreach (var key in parsed)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
known.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Treated as an empty watermark: the service then re-baselines silently
|
||||
// (empty known set + existing row = "first run" semantics), never re-announces.
|
||||
_logger.LogWarning(ex, "What's new notification state has malformed KnownEntryKeysJson — re-baselining");
|
||||
}
|
||||
}
|
||||
|
||||
return new WhatsNewNotificationState
|
||||
{
|
||||
KnownEntryKeys = known,
|
||||
LastDocsCommit = entity.GetString(LastDocsCommitProperty),
|
||||
LastRunUtc = entity.GetDateTime(LastRunUtcProperty),
|
||||
LastNotifiedUtc = entity.GetDateTime(LastNotifiedUtcProperty),
|
||||
LastNotifiedEntryCount = entity.GetInt32(LastNotifiedEntryCountProperty) ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.WhatsNew;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.WhatsNew
|
||||
{
|
||||
/// <summary>
|
||||
/// Hourly timer: diffs the portal's live <c>/whats-new.json</c> against the platform watermark
|
||||
/// and sends one digest of the newly published entries to every tenant channel that opted in
|
||||
/// (<c>NotificationChannel.NotifyOnWhatsNew</c>). Hourly — not on deploy — because the payload
|
||||
/// only changes with a web deploy and a delay of up to an hour is fine for product news.
|
||||
/// Offset to :20 so it does not collide with the :00 maintenance/SLA timers or the :30 sweep.
|
||||
/// </summary>
|
||||
public class WhatsNewNotificationFunction
|
||||
{
|
||||
private readonly WhatsNewNotificationService _service;
|
||||
private readonly ILogger<WhatsNewNotificationFunction> _logger;
|
||||
|
||||
public WhatsNewNotificationFunction(
|
||||
WhatsNewNotificationService service,
|
||||
ILogger<WhatsNewNotificationFunction> logger)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Function("WhatsNewNotification")]
|
||||
public async Task Run([TimerTrigger("0 20 * * * *")] object timer)
|
||||
{
|
||||
_logger.LogInformation("What's new notification timer fired at {Time}", System.DateTime.UtcNow);
|
||||
await _service.RunAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,13 @@ builder.Services.AddHttpClient<TelegramNotificationService>()
|
||||
// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer
|
||||
// vs. the platform Telegram bot). Transient: both transports are typed HttpClients.
|
||||
builder.Services.AddTransient<AutopilotMonitor.Functions.Services.Notifications.NotificationChannelDispatcher>();
|
||||
// What's new → channel digest (hourly timer). The feed client is a typed HttpClient reading the
|
||||
// portal's static whats-new.json; the service is transient because the dispatcher it wraps is.
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.WhatsNew.IWhatsNewFeedClient,
|
||||
AutopilotMonitor.Functions.Services.WhatsNew.WhatsNewFeedClient>()
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
|
||||
builder.Services.AddTransient<AutopilotMonitor.Functions.Services.WhatsNew.WhatsNewNotificationService>();
|
||||
// Durable channel notifications from the agent hot paths (ingest terminal alerts, hardware
|
||||
// rejection): the producer enqueues channel ids + alert, Functions/Queue/NotificationDispatchQueueFunction
|
||||
// re-resolves the channels from the tenant config and sends. Handler is transient because the
|
||||
|
||||
+102
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.WhatsNew;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
@@ -465,5 +466,106 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
var result = string.Join(" ", Array.FindAll(parts, p => p != null));
|
||||
return string.IsNullOrEmpty(result) ? "\u2013" : result;
|
||||
}
|
||||
|
||||
// ── What's new digest ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Entries rendered in full; the rest is folded into a "+N more" line.</summary>
|
||||
public const int WhatsNewMaxSections = 6;
|
||||
private const int WhatsNewMaxBodyLength = 280;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the "What's new" digest sent to channels with <c>NotifyOnWhatsNew</c>: one
|
||||
/// message per batch of newly published changelog entries (newest first), one section per
|
||||
/// entry. Entry text is inline markdown authored for the docs; renderers disagree on
|
||||
/// markdown dialects (Adaptive Card vs. Slack mrkdwn vs. Discord), so it is flattened to
|
||||
/// plain text here and the links live in the action buttons instead.
|
||||
/// </summary>
|
||||
public static NotificationAlert BuildWhatsNewAlert(
|
||||
IReadOnlyList<WhatsNewFeedEntry> newEntries,
|
||||
IReadOnlyDictionary<string, string>? docsUrls,
|
||||
string portalWhatsNewUrl)
|
||||
{
|
||||
if (newEntries == null) throw new ArgumentNullException(nameof(newEntries));
|
||||
if (newEntries.Count == 0) throw new ArgumentException("At least one entry is required.", nameof(newEntries));
|
||||
|
||||
var ordered = newEntries.OrderByDescending(e => e.AddedUtc).ToList();
|
||||
var platformCount = ordered.Count(e => e.Channel == WhatsNewFeed.PlatformChannel);
|
||||
var agentCount = ordered.Count - platformCount;
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "whats_new",
|
||||
Title = "\ud83c\udd95 What's new in Autopilot Monitor",
|
||||
Summary = BuildWhatsNewSummary(ordered.Count, platformCount, agentCount),
|
||||
Severity = NotificationSeverity.Info,
|
||||
ThemeColor = "0078D4",
|
||||
};
|
||||
|
||||
foreach (var entry in ordered.Take(WhatsNewMaxSections))
|
||||
{
|
||||
var channelLabel = entry.Channel == WhatsNewFeed.AgentChannel ? "Agent" : "Platform";
|
||||
var heading = $"{channelLabel} \u00b7 {entry.AddedUtc:MMM d, yyyy}";
|
||||
var title = StripInlineMarkdown(entry.Title);
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
heading += $" \u2014 {title}";
|
||||
|
||||
alert.Sections.Add(new NotificationSection
|
||||
{
|
||||
Title = heading,
|
||||
Text = Truncate(StripInlineMarkdown(entry.Body), WhatsNewMaxBodyLength),
|
||||
});
|
||||
}
|
||||
|
||||
var remaining = ordered.Count - WhatsNewMaxSections;
|
||||
if (remaining > 0)
|
||||
{
|
||||
alert.Sections.Add(new NotificationSection
|
||||
{
|
||||
Title = string.Empty,
|
||||
Text = $"+ {remaining} more update{(remaining == 1 ? "" : "s")} \u2014 see the full changelog.",
|
||||
});
|
||||
}
|
||||
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open What's new", Url = portalWhatsNewUrl });
|
||||
|
||||
// Changelog button for the channel with the most new entries (platform on a tie).
|
||||
var primaryChannel = agentCount > platformCount ? WhatsNewFeed.AgentChannel : WhatsNewFeed.PlatformChannel;
|
||||
if (docsUrls != null && docsUrls.TryGetValue(primaryChannel, out var docsUrl) && !string.IsNullOrWhiteSpace(docsUrl))
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Full changelog", Url = docsUrl });
|
||||
|
||||
return alert;
|
||||
}
|
||||
|
||||
private static string BuildWhatsNewSummary(int total, int platformCount, int agentCount)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (platformCount > 0) parts.Add($"{platformCount} platform");
|
||||
if (agentCount > 0) parts.Add($"{agentCount} agent");
|
||||
var breakdown = parts.Count == 2 ? $" ({string.Join(", ", parts)})" : string.Empty;
|
||||
return $"{total} new update{(total == 1 ? "" : "s")}{breakdown} just went live in the portal.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens the docs' inline markdown: <c>[label](url)</c> → label, <c>**x**</c>/<c>__x__</c> → x,
|
||||
/// <c>`x`</c> → x, <c>*x*</c>/<c>_x_</c> → x. Null-safe; whitespace is collapsed.
|
||||
/// </summary>
|
||||
internal static string StripInlineMarkdown(string? markdown)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(markdown))
|
||||
return string.Empty;
|
||||
|
||||
var text = markdown!;
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"\[([^\]]+)\]\([^)]*\)", "$1");
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"\*\*(.+?)\*\*", "$1");
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"__(.+?)__", "$1");
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"`([^`]*)`", "$1");
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", "$1");
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])", "$1");
|
||||
text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ");
|
||||
return text.Trim();
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
=> text.Length <= maxLength ? text : text[..(maxLength - 1)].TrimEnd() + "\u2026";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Flattens an alert into the plain-text form Telegram accepts. Telegram has no card
|
||||
/// format, so facts become "Name: Value" lines — that is also what carries an ops event's
|
||||
/// structured payload into the message.
|
||||
/// structured payload into the message. Sections (rule explanations, What's new entries)
|
||||
/// follow as title + text blocks, and openUrl actions as trailing "Title: url" lines.
|
||||
/// </summary>
|
||||
internal static string RenderAlertText(NotificationAlert alert)
|
||||
{
|
||||
@@ -300,6 +301,25 @@ namespace AutopilotMonitor.Functions.Services
|
||||
foreach (var fact in alert.Facts)
|
||||
sb.AppendLine($"{fact.Name}: {fact.Value}");
|
||||
|
||||
foreach (var section in alert.Sections)
|
||||
{
|
||||
if (string.IsNullOrEmpty(section.Title) && string.IsNullOrEmpty(section.Text))
|
||||
continue;
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrEmpty(section.Title))
|
||||
sb.AppendLine(section.Title);
|
||||
if (!string.IsNullOrEmpty(section.Text))
|
||||
sb.AppendLine(section.Text);
|
||||
}
|
||||
|
||||
var links = alert.Actions.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url)).ToList();
|
||||
if (links.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
foreach (var action in links)
|
||||
sb.AppendLine($"{action.Title}: {action.Url}");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.WhatsNew;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.WhatsNew
|
||||
{
|
||||
/// <summary>Reads the live What's new payload the portal serves.</summary>
|
||||
public interface IWhatsNewFeedClient
|
||||
{
|
||||
/// <summary>The parsed payload, or null when it could not be fetched or is off-shape.</summary>
|
||||
Task<WhatsNewFeed?> GetAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches <c>{PortalBaseUrl}/whats-new.json</c> — the static file the web deploy generates from
|
||||
/// the docs changelogs. Reading the portal's own copy (rather than the docs repo) keeps the
|
||||
/// notifier in lockstep with what the panel shows: an entry is announced when it becomes visible
|
||||
/// to users, not when it is committed to the docs.
|
||||
/// </summary>
|
||||
public sealed class WhatsNewFeedClient : IWhatsNewFeedClient
|
||||
{
|
||||
public const string FeedUrl = Constants.PortalBaseUrl + "/whats-new.json";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WhatsNewFeedClient> _logger;
|
||||
|
||||
public WhatsNewFeedClient(HttpClient httpClient, ILogger<WhatsNewFeedClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WhatsNewFeed?> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, FeedUrl);
|
||||
request.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true };
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("What's new feed returned HTTP {Status}", (int)response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var feed = WhatsNewFeed.Parse(json);
|
||||
if (feed == null)
|
||||
_logger.LogWarning("What's new feed at {Url} has an unexpected shape — skipping", FeedUrl);
|
||||
return feed;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch What's new feed from {Url}", FeedUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.WhatsNew;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.WhatsNew
|
||||
{
|
||||
/// <summary>
|
||||
/// Announces newly published What's new entries to every tenant channel that opted in
|
||||
/// (<see cref="NotificationChannel.NotifyOnWhatsNew"/>).
|
||||
/// <para>
|
||||
/// "New" means: present in the live <c>/whats-new.json</c> but absent from the platform-wide
|
||||
/// watermark (<see cref="WhatsNewNotificationState.KnownEntryKeys"/>) — i.e. the entry became
|
||||
/// visible in the portal since the last run. Diffing on ids rather than dates matters because
|
||||
/// the payload is regenerated only on web deploys: a bullet's docs commit date can be days
|
||||
/// older than the moment it actually appears. All new entries of a run go out as ONE digest
|
||||
/// per channel, so a release that adds five bullets is one message, not five.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ordering is claim-then-send: the watermark is advanced with an ETag compare-and-swap
|
||||
/// BEFORE any channel is contacted. A lost race means another run owns this batch and this
|
||||
/// one exits without sending; a crash after the claim loses the batch rather than
|
||||
/// double-announcing it (at-most-once, like every other channel notification here).
|
||||
/// The very first run — no state row, or an empty known set — only baselines and never
|
||||
/// announces: a fresh deployment must not blast the entire changelog at every tenant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class WhatsNewNotificationService
|
||||
{
|
||||
/// <summary>Portal deep link that opens the panel on load (WhatsNewPanelHost reads <c>?whats-new=</c>).</summary>
|
||||
internal static string PortalWhatsNewUrl(string channel)
|
||||
=> $"{Constants.PortalBaseUrl}/dashboard?whats-new={Uri.EscapeDataString(channel)}";
|
||||
|
||||
private readonly IWhatsNewFeedClient _feedClient;
|
||||
private readonly IWhatsNewNotificationStateRepository _stateRepo;
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly NotificationChannelDispatcher _channelDispatcher;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly ILogger<WhatsNewNotificationService> _logger;
|
||||
private readonly Func<DateTime> _nowProvider;
|
||||
|
||||
public WhatsNewNotificationService(
|
||||
IWhatsNewFeedClient feedClient,
|
||||
IWhatsNewNotificationStateRepository stateRepo,
|
||||
IConfigRepository configRepo,
|
||||
NotificationChannelDispatcher channelDispatcher,
|
||||
TelemetryClient telemetryClient,
|
||||
ILogger<WhatsNewNotificationService> logger,
|
||||
Func<DateTime>? nowProvider = null)
|
||||
{
|
||||
_feedClient = feedClient;
|
||||
_stateRepo = stateRepo;
|
||||
_configRepo = configRepo;
|
||||
_channelDispatcher = channelDispatcher;
|
||||
_telemetryClient = telemetryClient;
|
||||
_logger = logger;
|
||||
_nowProvider = nowProvider ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public sealed class RunResult
|
||||
{
|
||||
public bool FeedUnavailable { get; init; }
|
||||
public bool Baselined { get; init; }
|
||||
public bool LostRace { get; init; }
|
||||
public int NewEntries { get; init; }
|
||||
public int TenantsNotified { get; init; }
|
||||
public int ChannelsNotified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Timer entry point. Never throws for expected conditions (feed down, nothing new, lost race).</summary>
|
||||
public async Task<RunResult> RunAsync(CancellationToken ct = default)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var feed = await _feedClient.GetAsync(ct);
|
||||
if (feed == null)
|
||||
{
|
||||
_logger.LogWarning("What's new notifier: feed unavailable — skipping run");
|
||||
return new RunResult { FeedUnavailable = true };
|
||||
}
|
||||
|
||||
var (state, etag) = await _stateRepo.GetWithETagAsync();
|
||||
var now = _nowProvider();
|
||||
var currentKeys = new HashSet<string>(feed.Entries.Select(e => e.Key), StringComparer.Ordinal);
|
||||
|
||||
var next = new WhatsNewNotificationState
|
||||
{
|
||||
KnownEntryKeys = currentKeys,
|
||||
LastDocsCommit = feed.DocsCommit,
|
||||
LastRunUtc = now,
|
||||
LastNotifiedUtc = state?.LastNotifiedUtc,
|
||||
LastNotifiedEntryCount = state?.LastNotifiedEntryCount ?? 0,
|
||||
};
|
||||
|
||||
// First run (or a wiped/malformed watermark): record what is live, announce nothing.
|
||||
if (state == null || state.KnownEntryKeys.Count == 0)
|
||||
{
|
||||
var stored = await _stateRepo.TryUpsertAsync(next, etag);
|
||||
_logger.LogInformation("What's new notifier: baselined {Count} entries (docs @ {Commit}), stored={Stored}",
|
||||
currentKeys.Count, feed.DocsCommit ?? "unknown", stored);
|
||||
return new RunResult { Baselined = true };
|
||||
}
|
||||
|
||||
var newEntries = feed.Entries.Where(e => !state.KnownEntryKeys.Contains(e.Key)).ToList();
|
||||
if (newEntries.Count == 0)
|
||||
{
|
||||
// Only touch storage when the payload actually changed (entries rolled out of the window).
|
||||
if (!currentKeys.SetEquals(state.KnownEntryKeys))
|
||||
await _stateRepo.TryUpsertAsync(next, etag);
|
||||
_logger.LogDebug("What's new notifier: nothing new ({Count} entries live)", currentKeys.Count);
|
||||
return new RunResult();
|
||||
}
|
||||
|
||||
next.LastNotifiedUtc = now;
|
||||
next.LastNotifiedEntryCount = newEntries.Count;
|
||||
|
||||
if (!await _stateRepo.TryUpsertAsync(next, etag))
|
||||
{
|
||||
_logger.LogInformation("What's new notifier: watermark changed underneath this run — another run owns the batch");
|
||||
return new RunResult { LostRace = true, NewEntries = newEntries.Count };
|
||||
}
|
||||
|
||||
var (tenantsNotified, channelsNotified) = await DispatchAsync(feed, newEntries, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"What's new notifier: announced {New} new entries to {Channels} channels across {Tenants} tenants in {ElapsedMs}ms",
|
||||
newEntries.Count, channelsNotified, tenantsNotified, sw.ElapsedMilliseconds);
|
||||
|
||||
_telemetryClient.TrackEvent("WhatsNewNotificationSent", new Dictionary<string, string>
|
||||
{
|
||||
{ "NewEntries", newEntries.Count.ToString() },
|
||||
{ "PlatformEntries", newEntries.Count(e => e.Channel == WhatsNewFeed.PlatformChannel).ToString() },
|
||||
{ "AgentEntries", newEntries.Count(e => e.Channel == WhatsNewFeed.AgentChannel).ToString() },
|
||||
{ "Tenants", tenantsNotified.ToString() },
|
||||
{ "Channels", channelsNotified.ToString() },
|
||||
{ "DocsCommit", feed.DocsCommit ?? "unknown" },
|
||||
});
|
||||
|
||||
return new RunResult
|
||||
{
|
||||
NewEntries = newEntries.Count,
|
||||
TenantsNotified = tenantsNotified,
|
||||
ChannelsNotified = channelsNotified,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(int Tenants, int Channels)> DispatchAsync(
|
||||
WhatsNewFeed feed, List<WhatsNewFeedEntry> newEntries, CancellationToken ct)
|
||||
{
|
||||
var platformCount = newEntries.Count(e => e.Channel == WhatsNewFeed.PlatformChannel);
|
||||
var primaryChannel = newEntries.Count - platformCount > platformCount
|
||||
? WhatsNewFeed.AgentChannel
|
||||
: WhatsNewFeed.PlatformChannel;
|
||||
var alert = NotificationAlertBuilder.BuildWhatsNewAlert(newEntries, feed.DocsUrls, PortalWhatsNewUrl(primaryChannel));
|
||||
|
||||
var configs = await _configRepo.GetAllTenantConfigurationsAsync();
|
||||
int tenants = 0, channels = 0;
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
if (config.IsCurrentlyDisabled()) continue;
|
||||
|
||||
var targets = config.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnWhatsNew && !string.IsNullOrEmpty(c.Url))
|
||||
.ToList();
|
||||
if (targets.Count == 0) continue;
|
||||
|
||||
try
|
||||
{
|
||||
await _channelDispatcher.SendToChannelsAsync(targets, alert);
|
||||
tenants++;
|
||||
channels += targets.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "What's new notifier: dispatch failed for tenant {TenantId}", config.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
return (tenants, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,5 +114,6 @@ export const DIAG_ZIP_MAP = {
|
||||
'Pre-provisioned (White Glove) blobs carry a -preprov suffix — that is normal.',
|
||||
'No archive ≠ a problem: upload mode may be Off or OnFailure (so successful sessions have none).',
|
||||
'Log naming is kebab-case since 2026-08-18: agent.log, bootstrap-script.log (was bootstrap_agent.log), bootstrap-msi.log (was msi-bootstrap.log), crash-*.log (was crash_*.log), gather-rules-debug.log, ime-pattern-matches.log. Archives from older agents/bootstraps carry the old names.',
|
||||
'bootstrap-loader.log (since 2026-09-08) is the two-stage loader: it downloaded the installer, verified its publisher and started it. Its presence means the device was assigned the loader rather than the installer directly; the MDM-LOB MSI runs the same loader but logs to bootstrap-msi.log. bootstrap-script.log is the installer itself either way.',
|
||||
],
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models.WhatsNew;
|
||||
|
||||
namespace AutopilotMonitor.Shared.DataAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Persistence for the single <see cref="WhatsNewNotificationState"/> row. Same
|
||||
/// ETag-based compare-and-swap contract as <see cref="ISlaTenantStatusRepository"/>: the
|
||||
/// caller reads state + ETag, decides what is new, and commits the advanced watermark with
|
||||
/// the ETag it observed; a false return means another writer won and this run must not send.
|
||||
/// </summary>
|
||||
public interface IWhatsNewNotificationStateRepository
|
||||
{
|
||||
/// <summary>Returns (null, null) when the row does not exist yet (first run).</summary>
|
||||
Task<(WhatsNewNotificationState? State, string? ETag)> GetWithETagAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Conditional write: insert when <paramref name="ifMatchETag"/> is null (caller observed
|
||||
/// no row), otherwise replace if the ETag still matches. Returns false on conflict
|
||||
/// (row appeared / changed since the read) and on storage failure — never throws.
|
||||
/// </summary>
|
||||
Task<bool> TryUpsertAsync(WhatsNewNotificationState state, string? ifMatchETag);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// One Windows / MSI / Intune error-code catalog entry. Mirrors the structure of
|
||||
/// <c>Resources/error-codes.json</c> (generated by <c>scripts/error-codes/generate.mjs</c>)
|
||||
/// <c>Resources/error-codes.json</c> (generated by <c>tools/error-codes/generate.mjs</c>)
|
||||
/// and the TypeScript <c>ErrorCodeEntry</c> in the web app.
|
||||
/// </summary>
|
||||
public sealed class ErrorCodeEntry
|
||||
|
||||
@@ -10,8 +10,8 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// per tenant. Stored as a JSON array in <c>TenantConfiguration.NotificationChannelsJson</c>
|
||||
/// (camelCase contract shared with the web UI).
|
||||
/// <para>
|
||||
/// Routing granularity: enrollment/SLA events are opted into per channel via the
|
||||
/// <c>NotifyOn*</c> toggles below; analyze rules reference a channel by <see cref="Id"/>
|
||||
/// Routing granularity: enrollment/SLA events and What's new product updates are opted into
|
||||
/// per channel via the <c>NotifyOn*</c> toggles below; analyze rules reference a channel by <see cref="Id"/>
|
||||
/// (rule-level notify, see <c>RuleState.NotifyChannelIds</c>). A channel with all toggles
|
||||
/// off that is only referenced by rules acts as a dedicated "rules-only" channel.
|
||||
/// </para>
|
||||
@@ -94,6 +94,14 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// </summary>
|
||||
public bool NotifyOnSlaEvents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Receive "What's new" product updates: whenever new platform / agent changelog entries
|
||||
/// go live in the portal's What's new panel, one digest message per batch is sent to this
|
||||
/// channel (see <c>WhatsNewNotificationService</c>). Opt-in; the legacy synthesized channel
|
||||
/// never receives them.
|
||||
/// </summary>
|
||||
public bool NotifyOnWhatsNew { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parses <see cref="CustomHeadersJson"/> into request headers for the generic-webhook
|
||||
/// dispatcher. Empty unless <see cref="ProviderType"/> is GenericJson. Never throws.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models.WhatsNew
|
||||
{
|
||||
/// <summary>
|
||||
/// The portal's <c>/whats-new.json</c> as the backend reads it — the same payload the What's new
|
||||
/// panel renders (generated at web-deploy time from the docs changelogs by
|
||||
/// <c>scripts/whats-new/build-whats-new.js</c>). Entries carry a content-hash id and the docs
|
||||
/// commit date; the backend only needs ids (to diff against the last run) plus the display
|
||||
/// fields for the digest message.
|
||||
/// </summary>
|
||||
public sealed class WhatsNewFeed
|
||||
{
|
||||
public const string PlatformChannel = "platform";
|
||||
public const string AgentChannel = "agent";
|
||||
public static readonly string[] Channels = { PlatformChannel, AgentChannel };
|
||||
|
||||
public string? GeneratedUtc { get; set; }
|
||||
public string? DocsCommit { get; set; }
|
||||
public IReadOnlyList<WhatsNewFeedEntry> Entries { get; set; } = Array.Empty<WhatsNewFeedEntry>();
|
||||
|
||||
/// <summary>Channel → docs URL of the full changelog ("View all updates" target).</summary>
|
||||
public IReadOnlyDictionary<string, string> DocsUrls { get; set; } =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the payload. Returns null for anything off-shape (wrong schema version, missing
|
||||
/// channel, malformed entry) — the caller then skips the run rather than diffing against a
|
||||
/// half-read list, which would look like "everything was removed" or "everything is new".
|
||||
/// </summary>
|
||||
public static WhatsNewFeed? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json!);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
if (!root.TryGetProperty("schemaVersion", out var schema) || schema.ValueKind != JsonValueKind.Number || schema.GetInt32() != 1)
|
||||
return null;
|
||||
if (!root.TryGetProperty("channels", out var channels) || channels.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
var entries = new List<WhatsNewFeedEntry>();
|
||||
var docsUrls = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var channelName in Channels)
|
||||
{
|
||||
if (!channels.TryGetProperty(channelName, out var channel) || channel.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
if (!channel.TryGetProperty("entries", out var list) || list.ValueKind != JsonValueKind.Array)
|
||||
return null;
|
||||
|
||||
if (channel.TryGetProperty("docsUrl", out var docsUrl) && docsUrl.ValueKind == JsonValueKind.String)
|
||||
docsUrls[channelName] = docsUrl.GetString()!;
|
||||
|
||||
foreach (var item in list.EnumerateArray())
|
||||
{
|
||||
var entry = ParseEntry(channelName, item);
|
||||
if (entry == null)
|
||||
return null;
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return new WhatsNewFeed
|
||||
{
|
||||
GeneratedUtc = GetString(root, "generatedUtc"),
|
||||
DocsCommit = GetString(root, "docsCommit"),
|
||||
Entries = entries,
|
||||
DocsUrls = docsUrls,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static WhatsNewFeedEntry? ParseEntry(string channel, JsonElement item)
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
var id = GetString(item, "id");
|
||||
var addedUtc = GetString(item, "addedUtc");
|
||||
var body = GetString(item, "body");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(addedUtc) || body == null)
|
||||
return null;
|
||||
if (!DateTime.TryParse(addedUtc, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var added))
|
||||
return null;
|
||||
|
||||
return new WhatsNewFeedEntry
|
||||
{
|
||||
Channel = channel,
|
||||
Id = id!,
|
||||
AddedUtc = added,
|
||||
Period = GetString(item, "period") ?? string.Empty,
|
||||
Title = GetString(item, "title"),
|
||||
Body = body,
|
||||
Link = GetString(item, "link"),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string name)
|
||||
=> element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>One changelog bullet as published in <c>/whats-new.json</c>.</summary>
|
||||
public sealed class WhatsNewFeedEntry
|
||||
{
|
||||
/// <summary><see cref="WhatsNewFeed.PlatformChannel"/> or <see cref="WhatsNewFeed.AgentChannel"/>.</summary>
|
||||
public string Channel { get; set; } = default!;
|
||||
|
||||
/// <summary>Content-hash id (stable across builds while the bullet text is unchanged).</summary>
|
||||
public string Id { get; set; } = default!;
|
||||
|
||||
/// <summary>When the bullet was committed to the docs repo.</summary>
|
||||
public DateTime AddedUtc { get; set; }
|
||||
|
||||
/// <summary>The changelog period heading, e.g. "September 2026".</summary>
|
||||
public string Period { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Bold lead of platform bullets; agent bullets have none.</summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Inline markdown (**bold**, `code`, [label](url)).</summary>
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>First docs link of the bullet, if any.</summary>
|
||||
public string? Link { get; set; }
|
||||
|
||||
/// <summary>Channel-qualified key used for the "already announced" set — ids are per-channel hashes.</summary>
|
||||
public string Key => $"{Channel}:{Id}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models.WhatsNew
|
||||
{
|
||||
/// <summary>
|
||||
/// Platform-wide watermark of the What's new notifier: the channel-qualified entry keys that
|
||||
/// were present in <c>/whats-new.json</c> the last time it was processed. A run diffs the live
|
||||
/// payload against this set — keys not in it are "new" and get announced to every tenant
|
||||
/// channel that opted in. Stored as one row; written with an ETag compare-and-swap so two
|
||||
/// overlapping runs cannot both claim (and both announce) the same entries.
|
||||
/// </summary>
|
||||
public sealed class WhatsNewNotificationState
|
||||
{
|
||||
/// <summary>Entry keys (<c>{channel}:{id}</c>) of the last processed payload.</summary>
|
||||
public HashSet<string> KnownEntryKeys { get; set; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Docs commit the last processed payload was generated from (diagnostics only).</summary>
|
||||
public string? LastDocsCommit { get; set; }
|
||||
|
||||
/// <summary>When the state was last written.</summary>
|
||||
public DateTime? LastRunUtc { get; set; }
|
||||
|
||||
/// <summary>When a digest was last dispatched to tenant channels.</summary>
|
||||
public DateTime? LastNotifiedUtc { get; set; }
|
||||
|
||||
/// <summary>Number of entries announced in the last dispatched digest.</summary>
|
||||
public int LastNotifiedEntryCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"description": "Single-source catalog of Windows / MSI / Intune error codes, generated by scripts/error-codes/generate.mjs from primary sources — never edit by hand. Consumed by the backend (ErrorCodeCatalog.cs, ErrorCodeEnricher.cs), the web app (utils/errorCodeMap.ts via the sync-error-codes prebuild step) and the MCP server (lookup_error_code). Keys are lowercase 8-digit hex ('0x80070005') or decimal MSI exit codes ('1603'; only 0, 1601-1654, 3010 — other decimal exit codes are installer-defined). Win32 codes live only as 0x8007xxxx entries; HRESULT_FROM_WIN32 values in the MSI range resolve to the decimal entry at lookup time. confidence: high = MS Learn or the IME build named in source; medium = paraphrase of an enum member name or of an analysis rule; low = carried over from the v1 catalog without a primary source. category is one of: msi, win32, com, appx, windows-update, cbs, intune-win32, intune-mobile, mdm-enrollment, autopilot, entra-join, delivery-optimization. imeRetriesDuringEsp marks the MSI return codes the IME retries automatically during the Autopilot ESP. enforcementStates maps the IME app enforcement state numbers to their names.",
|
||||
"description": "Single-source catalog of Windows / MSI / Intune error codes, generated by tools/error-codes/generate.mjs from primary sources — never edit by hand. Consumed by the backend (ErrorCodeCatalog.cs, ErrorCodeEnricher.cs), the web app (utils/errorCodeMap.ts via the sync-error-codes prebuild step) and the MCP server (lookup_error_code). Keys are lowercase 8-digit hex ('0x80070005') or decimal MSI exit codes ('1603'; only 0, 1601-1654, 3010 — other decimal exit codes are installer-defined). Win32 codes live only as 0x8007xxxx entries; HRESULT_FROM_WIN32 values in the MSI range resolve to the decimal entry at lookup time. confidence: high = MS Learn or the IME build named in source; medium = paraphrase of an enum member name or of an analysis rule; low = carried over from the v1 catalog without a primary source. category is one of: msi, win32, com, appx, windows-update, cbs, intune-win32, intune-mobile, mdm-enrollment, autopilot, entra-join, delivery-optimization. imeRetriesDuringEsp marks the MSI return codes the IME retries automatically during the Autopilot ESP. enforcementStates maps the IME app enforcement state numbers to their names.",
|
||||
"entries": {
|
||||
"0": {"description":"The action completed successfully","confidence":"high","source":"msdoc:https://learn.microsoft.com/en-us/windows/win32/msi/error-codes","category":"msi","symbol":"ERROR_SUCCESS"},
|
||||
"1601": {"description":"The Windows Installer service couldn't be accessed. Contact your support personnel to verify that the Windows Installer service is properly registered","confidence":"high","source":"msdoc:https://learn.microsoft.com/en-us/windows/win32/msi/error-codes","category":"msi","symbol":"ERROR_INSTALL_SERVICE_FAILURE"},
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace AutopilotMonitor.Shared.Services
|
||||
/// <summary>
|
||||
/// Single-source-of-truth lookup for Windows / MSI / Intune error codes. Loaded once
|
||||
/// from the embedded <c>Resources/error-codes.json</c> resource (schemaVersion 2,
|
||||
/// generated by <c>scripts/error-codes/generate.mjs</c>) and exposed via dictionaries
|
||||
/// generated by <c>tools/error-codes/generate.mjs</c>) and exposed via dictionaries
|
||||
/// keyed by normalised code string (lowercase hex, e.g. <c>0x80070005</c>, or decimal MSI
|
||||
/// exit code, e.g. <c>1603</c>), by symbol and by enforcement state.
|
||||
/// <para>
|
||||
|
||||
@@ -156,6 +156,7 @@ interface ChannelView {
|
||||
notifyOnSuccess?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
notifyOnSlaEvents?: boolean;
|
||||
notifyOnWhatsNew?: boolean;
|
||||
}
|
||||
|
||||
/** Channel list when migrated, else the legacy single-webhook fields — mirrors TenantConfiguration.GetNotificationChannels. */
|
||||
@@ -194,6 +195,7 @@ function ChannelRows({ config }: { config: TenantConfiguration }) {
|
||||
<ConfigRow label="Notify On Success" display={formatValue(ch.notifyOnSuccess ?? false)} />
|
||||
<ConfigRow label="Notify On Failure" display={formatValue(ch.notifyOnFailure ?? false)} />
|
||||
<ConfigRow label="Notify On SLA" display={formatValue(ch.notifyOnSlaEvents ?? false)} />
|
||||
<ConfigRow label="Notify On What's New" display={formatValue(ch.notifyOnWhatsNew ?? false)} />
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function NotificationsSection({
|
||||
tone="sky"
|
||||
iconPath="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
title="Notifications"
|
||||
subtitle="Send notifications to one or more channels — Teams, Slack, Discord, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives."
|
||||
subtitle="Send notifications to one or more channels — Teams, Slack, Discord, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives, including What's new product updates."
|
||||
docsPath={DOCS_PATHS.notifications}
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
@@ -65,7 +65,7 @@ export default function NotificationsSection({
|
||||
<div className="space-y-4">
|
||||
{channels.length === 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
No channels configured yet. Add a channel to receive enrollment, SLA, or rule notifications.
|
||||
No channels configured yet. Add a channel to receive enrollment, SLA, rule, or What's new notifications.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface NotificationChannel {
|
||||
notifyOnHardwareRejection?: boolean;
|
||||
/** Receive SLA breach/resolved/consecutive-failure alerts (evaluation gated by tenant-level SLA flags). */
|
||||
notifyOnSlaEvents?: boolean;
|
||||
/** Receive "What's new" product updates (one digest per batch of new changelog entries). */
|
||||
notifyOnWhatsNew?: boolean;
|
||||
}
|
||||
|
||||
/** Backend cap (NotificationChannel.MaxChannelsPerTenant) — excess entries are ignored at parse time. */
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ModalPortal } from "./ModalPortal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useWhatsNew } from "@/hooks/useWhatsNew";
|
||||
import { closeWhatsNew } from "@/lib/whatsNewStore";
|
||||
import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown";
|
||||
import { isPublicPath } from "@/lib/hostRouting";
|
||||
import { route } from "@/lib/routes";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import {
|
||||
formatBadgeCount,
|
||||
@@ -17,6 +22,9 @@ import {
|
||||
|
||||
const CHANNEL_LABEL: Record<WhatsNewChannel, string> = { platform: "Platform", agent: "Agent" };
|
||||
|
||||
/** Tenant settings section where a channel's "What's new" toggle lives. */
|
||||
const NOTIFICATION_SETTINGS_HREF = route("/settings/tenant/notifications");
|
||||
|
||||
/**
|
||||
* The red unread counter used on the navbar icons, the menu rows and the panel tabs —
|
||||
* same size and cap as the notification bell's badge.
|
||||
@@ -124,12 +132,19 @@ function SkeletonRows() {
|
||||
*/
|
||||
export function WhatsNewPanel({ rootClassName }: { rootClassName?: string }) {
|
||||
const wn = useWhatsNew();
|
||||
const { user } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
// Marks as they were when the panel opened: the "New" labels must not vanish under the
|
||||
// reader's eyes when the channel is marked seen a moment later.
|
||||
const [seenAtOpen] = useState<WhatsNewSeen>(() => wn.seen);
|
||||
const [openedAt] = useState(() => new Date());
|
||||
|
||||
// Only whoever can actually flip the channel toggle gets the "subscribe" link, and only on
|
||||
// the portal — /settings/tenant/… does not exist on the public host.
|
||||
const canConfigureNotifications =
|
||||
wn.tracksUnseen && (user?.isTenantAdmin === true || user?.isGlobalAdmin === true) && !isPublicPath(pathname);
|
||||
|
||||
const channel = wn.panelChannel;
|
||||
const entries = wn.entries(channel);
|
||||
const newIds = new Set(wn.tracksUnseen ? unseenEntries(entries, seenAtOpen[channel], openedAt).map(e => e.id) : []);
|
||||
@@ -263,6 +278,22 @@ export function WhatsNewPanel({ rootClassName }: { rootClassName?: string }) {
|
||||
<ExternalIcon className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
{canConfigureNotifications && (
|
||||
<p className="mt-3 text-center text-xs text-[var(--lp-ink-faint)]">
|
||||
<Link
|
||||
href={NOTIFICATION_SETTINGS_HREF}
|
||||
prefetch={false}
|
||||
onClick={() => {
|
||||
trackEvent("whats_new_notification_settings_clicked", { channel });
|
||||
closeWhatsNew();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 font-medium text-[var(--lp-ink-soft)] hover:text-[var(--lp-accent-ink)] hover:underline"
|
||||
>
|
||||
<BellIcon className="w-3.5 h-3.5" />
|
||||
Get these updates in Teams, Slack or Discord
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: stri
|
||||
{ key: "notifyOnFailure", label: "Failure", hint: "Enrollment failed" },
|
||||
{ key: "notifyOnHardwareRejection", label: "Hardware rejection", hint: "Device rejected by the hardware whitelist" },
|
||||
{ key: "notifyOnSlaEvents", label: "SLA", hint: "SLA breach / resolved / consecutive failures (which breaches are evaluated is configured under SLA Targets)" },
|
||||
{ key: "notifyOnWhatsNew", label: "What's new", hint: "Product updates: one digest whenever new platform or agent changelog entries go live in the portal's What's new panel" },
|
||||
];
|
||||
|
||||
type HeaderRow = { key: string; value: string };
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface WhatsNewEntry {
|
||||
|
||||
export interface WhatsNewChannelData {
|
||||
docsUrl: string;
|
||||
/**
|
||||
* Period blocks in the order the changelog lists them; inside one block newest first,
|
||||
* ties in authored order. The generator guarantees this — the panel renders the array as
|
||||
* it comes, so an entry the badge counts is always at the top of its period.
|
||||
*/
|
||||
entries: WhatsNewEntry[];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The authoritative copy lives at:
|
||||
* src/Shared/AutopilotMonitor.Shared/Resources/error-codes.json
|
||||
* (generated by scripts/error-codes/generate.mjs at the repo root; never edited by hand).
|
||||
* (generated by tools/error-codes/generate.mjs at the repo root; never edited by hand).
|
||||
*
|
||||
* The backend (ErrorCodeCatalog.cs) embeds that file; the web app (utils/errorCodeMap.ts)
|
||||
* imports the copy this script writes to utils/error-codes.json. The copy is validated,
|
||||
|
||||
@@ -219,24 +219,28 @@ describe("buildPayload", () => {
|
||||
expect(payload.channels.agent.docsUrl).toBe("https://docs.example/changelog/agent-changelog");
|
||||
});
|
||||
|
||||
it("keeps the three newest periods only and preserves file order", () => {
|
||||
const platformEntry = (title: string) => {
|
||||
const entry = payload.channels.platform.entries.find(e => e.title === title);
|
||||
if (!entry) throw new Error(`no platform entry titled ${title}`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
it("keeps the three newest periods only and preserves the file order of the blocks", () => {
|
||||
const periods = payload.channels.platform.entries.map(e => e.period);
|
||||
expect(periods).toEqual(["September 2026", "September 2026", "September 2026", "August 2026", "July 2026"]);
|
||||
});
|
||||
|
||||
it("dates bullets from blame and uncommitted ones from now", () => {
|
||||
const [first, second] = payload.channels.platform.entries;
|
||||
expect(first.addedUtc).toBe(new Date(1757200000 * 1000).toISOString());
|
||||
expect(second.addedUtc).toBe(now.toISOString());
|
||||
expect(platformEntry("Filter widgets by priority").addedUtc).toBe(new Date(1757200000 * 1000).toISOString());
|
||||
expect(platformEntry("Wrapped bullet").addedUtc).toBe(now.toISOString());
|
||||
});
|
||||
|
||||
it("splits platform titles, rewrites links and picks the first as the read-more target", () => {
|
||||
const first = payload.channels.platform.entries[0];
|
||||
expect(first.title).toBe("Filter widgets by priority");
|
||||
expect(first.body).toBe(
|
||||
const entry = platformEntry("Filter widgets by priority");
|
||||
expect(entry.body).toBe(
|
||||
"Every chip filters the list. See [Dashboard](https://docs.example/portal-guide/dashboard-and-sessions#search-syntax) and [Concepts](https://docs.example/rules/analyze-rules/concepts).",
|
||||
);
|
||||
expect(first.link).toBe("https://docs.example/portal-guide/dashboard-and-sessions#search-syntax");
|
||||
expect(entry.link).toBe("https://docs.example/portal-guide/dashboard-and-sessions#search-syntax");
|
||||
});
|
||||
|
||||
it("leaves agent bullets untitled with the sentence as body", () => {
|
||||
@@ -255,6 +259,56 @@ describe("buildPayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry order", () => {
|
||||
const now = new Date("2026-09-08T10:00:00.000Z");
|
||||
// Two commits, three bullets: the newest bullet is authored BELOW an older one — the
|
||||
// topic grouping the changelogs use. Without ordering it would hide among older entries
|
||||
// while the badge still counted it.
|
||||
const MD = [
|
||||
"# Platform Changelog",
|
||||
"",
|
||||
"## September 2026",
|
||||
"",
|
||||
"* **Older, listed first** — body.",
|
||||
"* **Newest, listed last** — body.",
|
||||
"* **Same commit as the newest** — body.",
|
||||
"",
|
||||
"## August 2026",
|
||||
"",
|
||||
"* **August** — body.",
|
||||
].join("\n");
|
||||
const order = (markdown: string, blame: Record<number, number | null>) =>
|
||||
buildPayload({
|
||||
docsUrl: "https://docs.example",
|
||||
docsCommit: null,
|
||||
summary: SUMMARY,
|
||||
now,
|
||||
channels: { platform: { markdown, blame: blameFor(blame) }, agent: { markdown: AGENT_MD, blame: blameFor({ 7: 1757300000, 8: 1757250000 }) } },
|
||||
}).channels.platform.entries;
|
||||
|
||||
it("puts the newest bullet of a period first and keeps ties in authored order", () => {
|
||||
const entries = order(MD, { 5: 1757100000, 6: 1757200000, 7: 1757200000, 11: 1756000000 });
|
||||
expect(entries.map(e => e.title)).toEqual([
|
||||
"Newest, listed last",
|
||||
"Same commit as the newest",
|
||||
"Older, listed first",
|
||||
"August",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reorder across periods — an old bullet stays in its own block", () => {
|
||||
// August bullet dated after every September bullet: the blocks keep their file order.
|
||||
const entries = order(MD, { 5: 1757100000, 6: 1757200000, 7: 1757200000, 11: 1757900000 });
|
||||
expect(entries.map(e => e.period)).toEqual(["September 2026", "September 2026", "September 2026", "August 2026"]);
|
||||
});
|
||||
|
||||
it("sorts an uncommitted bullet to the top — it is the newest a local build knows", () => {
|
||||
const entries = order(MD, { 5: 1757100000, 6: 1757200000, 7: null, 11: 1756000000 });
|
||||
expect(entries[0].title).toBe("Same commit as the newest");
|
||||
expect(entries[0].addedUtc).toBe(now.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("emptyPayload", () => {
|
||||
it("has the full shape with no entries", () => {
|
||||
const p = emptyPayload("https://docs.example");
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
* `## <Period>` blocks with `* ` bullets — and turns it into the JSON the portal
|
||||
* panel renders. Nothing here adds meaning the markdown does not carry; the only
|
||||
* derived facts are the per-bullet publish date (from `git blame`, supplied by the
|
||||
* caller) and the absolute docs URL of every relative link.
|
||||
* caller), the absolute docs URL of every relative link, and the order of the bullets
|
||||
* within a period.
|
||||
*
|
||||
* Ordering: the changelogs are authored newest-first, but related bullets are grouped by
|
||||
* topic, so a new entry can end up below older ones in the file. The panel counts unseen
|
||||
* entries by date, so a reader could not find the bullet the counter meant. The payload
|
||||
* therefore sorts each period's bullets by publish date, newest first — every unseen entry
|
||||
* sits at the top of its period and the badge is countable. The docs page keeps its
|
||||
* authored order; it shows no dates.
|
||||
*/
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
@@ -283,6 +291,8 @@ function toIso(epochSeconds, fallbackIso) {
|
||||
/**
|
||||
* Builds one channel's entry list from markdown + blame dates.
|
||||
* `nowIso` dates bullets git has not committed yet (local runs on a dirty tree).
|
||||
* Period blocks keep their file order; the bullets inside one block are ordered by
|
||||
* publish date, newest first — see the ordering note at the top of this file.
|
||||
* @returns {Entry[]}
|
||||
*/
|
||||
function buildChannelEntries(markdown, blameByLine, ctx, nowIso) {
|
||||
@@ -290,11 +300,12 @@ function buildChannelEntries(markdown, blameByLine, ctx, nowIso) {
|
||||
/** @type {Entry[]} */
|
||||
const entries = [];
|
||||
for (const block of blocks) {
|
||||
/** @type {Entry[]} */
|
||||
const blockEntries = [];
|
||||
for (const bullet of block.bullets) {
|
||||
if (entries.length >= MAX_ENTRIES) return entries;
|
||||
const { title, body } = splitTitle(bullet.text);
|
||||
const { text, link } = rewriteLinks(body, ctx);
|
||||
entries.push({
|
||||
blockEntries.push({
|
||||
id: stableId(`${block.period}|${bullet.text}`),
|
||||
addedUtc: toIso(blameByLine.get(bullet.line), nowIso),
|
||||
period: block.period,
|
||||
@@ -303,6 +314,13 @@ function buildChannelEntries(markdown, blameByLine, ctx, nowIso) {
|
||||
link,
|
||||
});
|
||||
}
|
||||
// Newest first inside the period; ties keep the authored order, so the bullets of one
|
||||
// commit stay in the sequence they were written in.
|
||||
blockEntries.sort((a, b) => Date.parse(b.addedUtc) - Date.parse(a.addedUtc));
|
||||
for (const entry of blockEntries) {
|
||||
if (entries.length >= MAX_ENTRIES) return entries;
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Wrapper around the shared error-codes catalog (schemaVersion 2).
|
||||
*
|
||||
* The catalog lives at `src/Shared/AutopilotMonitor.Shared/Resources/error-codes.json`
|
||||
* (generated by `scripts/error-codes/generate.mjs` at the repo root) and is synced into
|
||||
* (generated by `tools/error-codes/generate.mjs` at the repo root) and is synced into
|
||||
* `utils/error-codes.json` at build time (`scripts/sync-error-codes.js`, wired into the
|
||||
* `prebuild` npm script). The sync validates the catalog, shortens `source` to its kind and
|
||||
* drops the enforcement-state map; backend, MCP and web resolve from the same entries.
|
||||
|
||||
@@ -7,7 +7,7 @@ hand: a wrong or missing entry is fixed in the generator (a source, a pin, a fam
|
||||
regenerated, so the next regeneration cannot lose the fix.
|
||||
|
||||
```
|
||||
IME_ENUMS_JSON=<path> node scripts/error-codes/generate.mjs [--cache-dir DIR] [--check]
|
||||
IME_ENUMS_JSON=<path> node tools/error-codes/generate.mjs [--cache-dir DIR] [--check]
|
||||
```
|
||||
|
||||
- `IME_ENUMS_JSON` — enum extract of one Intune Management Extension build (`imeVersion`,
|
||||
@@ -6,7 +6,7 @@
|
||||
* sources and writes the web copy through the web sync script. Every entry carries the
|
||||
* source it was taken from; nothing is typed in from memory.
|
||||
*
|
||||
* node scripts/error-codes/generate.mjs [--cache-dir DIR] [--check]
|
||||
* node tools/error-codes/generate.mjs [--cache-dir DIR] [--check]
|
||||
*
|
||||
* IME_ENUMS_JSON required — path to the enum extract of one Intune Management Extension
|
||||
* build (customErrorCodes, enforcementStates, msiRetriableErrorCodes,
|
||||
@@ -492,7 +492,7 @@ async function main() {
|
||||
const header = {
|
||||
schemaVersion: 2,
|
||||
description:
|
||||
"Single-source catalog of Windows / MSI / Intune error codes, generated by scripts/error-codes/generate.mjs from primary sources — " +
|
||||
"Single-source catalog of Windows / MSI / Intune error codes, generated by tools/error-codes/generate.mjs from primary sources — " +
|
||||
"never edit by hand. Consumed by the backend (ErrorCodeCatalog.cs, ErrorCodeEnricher.cs), the web app (utils/errorCodeMap.ts via the " +
|
||||
"sync-error-codes prebuild step) and the MCP server (lookup_error_code). Keys are lowercase 8-digit hex ('0x80070005') or decimal MSI exit " +
|
||||
"codes ('1603'; only 0, 1601-1654, 3010 — other decimal exit codes are installer-defined). Win32 codes live only as 0x8007xxxx entries; " +
|
||||
Reference in New Issue
Block a user