diff --git a/.github/scripts/Assert-HostsFromRegistry.ps1 b/.github/scripts/Assert-HostsFromRegistry.ps1 new file mode 100644 index 00000000..6c9fad39 --- /dev/null +++ b/.github/scripts/Assert-HostsFromRegistry.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS + Fails when a workflow or a CI helper spells out a host instead of reading Get-Endpoints.ps1. + +.DESCRIPTION + The CI counterpart of the URL-registry guard tests (HardcodedUrlGuardTests, + hardcodedUrls.guard.test.ts, hardcoded-urls.guard.test.ts). Enforced: every host the registry + defines -- derived from it, so the list cannot fall behind -- plus any Azure storage, App Service + or autopilotmonitor.com host, so a new or misspelled account is caught as well. + + Scans .github/workflows/*.yml and .github/scripts/*.ps1 except the registry itself. Comment lines + and PowerShell block comments do not count: comments may cite URLs. + +.PARAMETER Root + Repository root to scan. Defaults to the parent of .github/scripts. +#> +[CmdletBinding()] +param( + [string]$Root +) + +$ErrorActionPreference = 'Stop' + +if (-not $Root) { + $Root = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +} + +$endpoints = & (Join-Path $PSScriptRoot 'Get-Endpoints.ps1') +$registryHosts = $endpoints.Values | ForEach-Object { ([uri]$_).Host } | Sort-Object -Unique +$patterns = @($registryHosts | ForEach-Object { [regex]::Escape($_) }) + @( + '[a-z0-9-]+\.(blob|table|queue|file)\.core\.windows\.net' + '[a-z0-9-]+\.azurewebsites\.net' + '([a-z0-9-]+\.)*autopilotmonitor\.com' +) +$hostPattern = [regex]::new('(' + ($patterns -join '|') + ')', 'IgnoreCase') + +$files = @( + Get-ChildItem -Path (Join-Path $Root '.github/workflows') -File -Filter '*.yml' + Get-ChildItem -Path (Join-Path $Root '.github/workflows') -File -Filter '*.yaml' + Get-ChildItem -Path (Join-Path $Root '.github/scripts') -File -Filter '*.ps1' | + Where-Object Name -ne 'Get-Endpoints.ps1' +) + +$findings = 0 +foreach ($file in $files) { + $relative = [System.IO.Path]::GetRelativePath($Root, $file.FullName) -replace '\\', '/' + $isPowerShell = $file.Extension -eq '.ps1' + $inBlock = $false + $lineNumber = 0 + foreach ($line in [System.IO.File]::ReadAllLines($file.FullName)) { + $lineNumber++ + $code = $line + if ($isPowerShell) { + if ($inBlock) { + $end = $code.IndexOf('#>') + if ($end -lt 0) { continue } + $code = $code.Substring($end + 2) + $inBlock = $false + } + $start = $code.IndexOf('<#') + if ($start -ge 0) { + $end = $code.IndexOf('#>', $start + 2) + if ($end -lt 0) { + $code = $code.Substring(0, $start) + $inBlock = $true + } else { + $code = $code.Remove($start, $end + 2 - $start) + } + } + } + if ($code.TrimStart().StartsWith('#')) { continue } + + $match = $hostPattern.Match($code) + if ($match.Success) { + Write-Host "::error file=$relative,line=$lineNumber::$($match.Value) is spelled out here -- take it from .github/scripts/Get-Endpoints.ps1." + $findings++ + } + } +} + +if ($findings -gt 0) { + throw "$findings host literal(s) outside the endpoint registry." +} +Write-Host "No host literal outside .github/scripts/Get-Endpoints.ps1 ($($files.Count) files scanned)." diff --git a/.github/scripts/Get-Endpoints.ps1 b/.github/scripts/Get-Endpoints.ps1 new file mode 100644 index 00000000..2a453457 --- /dev/null +++ b/.github/scripts/Get-Endpoints.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + The one place the workflows and the release scripts take their hosts from. + +.DESCRIPTION + CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts): + every own host the workflows, the .github/scripts helpers and the local release build talk to, + plus the Authenticode timestamp server, is defined here once. Assert-HostsFromRegistry.ps1 + (Workflow gates) fails on any of these hosts spelled out elsewhere under .github/. + + The storage URLs derive from the account name, so blob and table can never point at two + different accounts. + + A file in the repository rather than a GitHub variable: the local release build reads it too, + and bearer tokens are sent to these URLs -- where a token goes stays a reviewed line in git. + +.PARAMETER ExportToGitHubEnv + Write every entry to $GITHUB_ENV instead of returning it, so the following steps of the job + read them as environment variables ($env:AGENT_CONTAINER_URL, ${{ env.AGENT_CONTAINER_URL }}). + +.OUTPUTS + [ordered] hashtable, keyed by the names the environment variables carry. + +.EXAMPLE + $endpoints = & "$PSScriptRoot/Get-Endpoints.ps1" + Invoke-WebRequest -Uri "$($endpoints.AGENT_CONTAINER_URL)/version.json" +#> +[CmdletBinding()] +param( + [switch]$ExportToGitHubEnv +) + +$ErrorActionPreference = 'Stop' + +$storageAccount = 'autopilotmonitoreu' +# Pre-migration account in another tenant; only the fail-soft legacy mirror still writes there. +$legacyStorageAccount = 'autopilotmonitor' + +$blob = "https://$storageAccount.blob.core.windows.net" +$table = "https://$storageAccount.table.core.windows.net" + +$endpoints = [ordered]@{ + STORAGE_BLOB_URL = $blob + STORAGE_TABLE_URL = $table + AGENT_CONTAINER_URL = "$blob/agent" + VERSIONS_CONTAINER_URL = "$blob/versions" + STATS_CONTAINER_URL = "$blob/stats" + # Platform settings row; CI writes only its agent and bootstrap version columns. + ADMIN_CONFIG_URL = "$table/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')" + LEGACY_AGENT_CONTAINER_URL = "https://$legacyStorageAccount.blob.core.windows.net/agent" + # Front Door in front of the agent container: what customer scripts download from. + DOWNLOAD_ALIAS_URL = 'https://download.autopilotmonitor.com/agent' + API_BASE_URL = 'https://autopilotmonitor-api-eu.azurewebsites.net' + MCP_BASE_URL = 'https://mcp.autopilotmonitor.com' + PORTAL_BASE_URL = 'https://portal.autopilotmonitor.com' + WWW_BASE_URL = 'https://www.autopilotmonitor.com' + DOCS_BASE_URL = 'https://docs.autopilotmonitor.com' + CODE_SIGN_TIMESTAMP_URL = 'http://timestamp.acs.microsoft.com' +} + +if ($ExportToGitHubEnv) { + if ([string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { + throw '-ExportToGitHubEnv only works inside a GitHub Actions step.' + } + $endpoints.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" } | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $endpoints.GetEnumerator() | ForEach-Object { Write-Host "$($_.Key) = $($_.Value)" } + return +} + +return $endpoints diff --git a/.github/scripts/Publish-BootstrapScripts.ps1 b/.github/scripts/Publish-BootstrapScripts.ps1 index 614b64a4..bbee78c0 100644 --- a/.github/scripts/Publish-BootstrapScripts.ps1 +++ b/.github/scripts/Publish-BootstrapScripts.ps1 @@ -81,7 +81,6 @@ param( [string]$SigningToken, [string]$KeyVaultUrl, [string]$CertificateName, - [string]$TimestampUrl = 'http://timestamp.acs.microsoft.com', [string]$ExpectedPublisher, [string]$SignedOutputDir, [switch]$DryRun @@ -93,10 +92,13 @@ if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path } -$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent' -$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent' -$AliasUrl = 'https://download.autopilotmonitor.com/agent' -$TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')" +# Every host, the timestamp server included, from the one endpoint registry. +$Endpoints = & (Join-Path $PSScriptRoot 'Get-Endpoints.ps1') +$ContainerUrl = $Endpoints.AGENT_CONTAINER_URL +$LegacyContainerUrl = $Endpoints.LEGACY_AGENT_CONTAINER_URL +$AliasUrl = $Endpoints.DOWNLOAD_ALIAS_URL +$TableUrl = $Endpoints.ADMIN_CONFIG_URL +$TimestampUrl = $Endpoints.CODE_SIGN_TIMESTAMP_URL $BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1' $BootstrapBlob = 'Install-AutopilotMonitor.ps1' @@ -210,7 +212,7 @@ function Set-PublishSetSignature { --timestamp-rfc3161 $TimestampUrl ` --file-digest sha256 ` --description 'Autopilot Monitor bootstrap' ` - --description-url 'https://www.autopilotmonitor.com' ` + --description-url $Endpoints.WWW_BASE_URL ` $paths # Native tool: PowerShell does not throw on a non-zero exit, and an unnoticed signing @@ -327,8 +329,10 @@ $loaderVersion = Get-BootstrapScriptVersion -Content $loaderContent -Origin $Loa 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"' +# The anchors are the customer scripts' own text, built from the alias in the registry. Should +# the alias ever move, the scripts have to move with it -- until they do, the render stops here. +$stableUrlLiteral = '$AgentDownloadUrl = "' + $AliasUrl + '/AutopilotMonitor-Agent.zip"' +$devUrlLiteral = '$AgentDownloadUrl = "' + $AliasUrl + '/AutopilotMonitor-Agent-dev.zip"' $stableManLiteral = '$VersionJsonName = "version.json"' $devManLiteral = '$VersionJsonName = "version-dev.json"' @@ -339,8 +343,8 @@ $devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace( Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)" # 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"' +$stableLoaderLiteral = '$BootstrapUrl = "' + $AliasUrl + '/Install-AutopilotMonitor.ps1"' +$devLoaderLiteral = '$BootstrapUrl = "' + $AliasUrl + '/Install-AutopilotMonitor-Dev.ps1"' if ($loaderContent.IndexOf($stableLoaderLiteral) -lt 0) { throw "Loader dev-render: anchor literal missing in $LoaderSource" diff --git a/.github/workflows/action-pins.yml b/.github/workflows/action-pins.yml index b54bb06f..daf3bd6c 100644 --- a/.github/workflows/action-pins.yml +++ b/.github/workflows/action-pins.yml @@ -1,12 +1,15 @@ name: Workflow gates -# Two checks over the workflow files themselves: +# Three checks over the workflow files and their helper scripts: # * verify-pins -- every third-party action is referenced by a full commit SHA plus a # version comment (stricter than zizmor's unpinned-uses audit). # * zizmor -- static analysis of every workflow (permissions, credential # persistence, template injection, ...). Configuration and the # reasoned ignores live in zizmor.yml at the repository root; the # same run is expected locally before a workflow change is committed. +# * hosts -- no workflow or .github/scripts helper spells out a host; every one +# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL +# registry (the C#, web and MCP registries have guard tests of their own). # # Every third-party action must be referenced by a full commit SHA, never by a tag. # Tags are mutable: whoever controls an action repository can repoint v4 at new code, @@ -34,10 +37,10 @@ name: Workflow gates on: push: branches: [main] - paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml'] + paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml'] pull_request: branches: [main] - paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml'] + paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml'] workflow_dispatch: permissions: @@ -106,3 +109,17 @@ jobs: with: version: 1.30.1 min-severity: low + + hosts: + name: Hosts only from the endpoint registry + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The enforced host list is derived from the registry itself, plus every Azure storage, + # App Service and autopilotmonitor.com host, so it cannot fall behind a new entry. + - name: No host literal outside .github/scripts/Get-Endpoints.ps1 + shell: pwsh + run: ./.github/scripts/Assert-HostsFromRegistry.ps1 diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index 1b0dc2a2..3c42a252 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -60,13 +60,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. + # Authenticode via Azure Key Vault; identity and vault come from repository variables, the + # timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here. # CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step. CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }} CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }} CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }} CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }} - CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com jobs: # Runs only this repository's scripts. The deploy identity trusts the `production` @@ -89,6 +89,11 @@ jobs: with: persist-credentials: false + # Every host in this workflow comes from the one endpoint registry. + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + # Every released build is signed; a missing variable stops the run before a build # number is burnt. - name: Check code-signing configuration @@ -129,7 +134,7 @@ jobs: $override = [int]$env:BUILD_NUMBER_OVERRIDE } - $counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt" + $counterUrl = "$env:AGENT_CONTAINER_URL/buildcounter-v$($env:LINE_MAJOR).txt" $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -Override $override $csproj = Get-Content $env:CSPROJ_PATH -Raw @@ -238,6 +243,11 @@ jobs: with: persist-credentials: false + # Storage, legacy mirror, docs link, timestamp server: all from the endpoint registry. + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + - name: Download unsigned build output uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -336,7 +346,7 @@ jobs: --timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" ` --file-digest sha256 ` --description "Autopilot Monitor Agent" ` - --description-url "https://www.autopilotmonitor.com" ` + --description-url "$env:WWW_BASE_URL" ` ($targets | ForEach-Object { $_.FullName }) if ($LASTEXITCODE -ne 0) { @@ -485,13 +495,13 @@ jobs: ZIP_SHA256: ${{ steps.artifact.outputs.sha256 }} EXE_SHA256: ${{ steps.artifact.outputs.exe_sha256 }} run: | - $containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent" + $containerUrl = $env:AGENT_CONTAINER_URL $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 # Legacy mirror (transition until customers moved to the download.-alias): # blobs are ALSO pushed to the old account so the bootstrap URL baked into # customers' Intune scripts keeps serving current bits. Fail-soft -- a # legacy hiccup must never fail a release. Counter lives ONLY on eu. - $legacyContainerUrl = "https://autopilotmonitor.blob.core.windows.net/agent" + $legacyContainerUrl = $env:LEGACY_AGENT_CONTAINER_URL $legacySasToken = "${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}".TrimStart('?') function Send-Blob { @@ -542,7 +552,6 @@ jobs: -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 } @@ -556,7 +565,7 @@ jobs: $sha256 = $env:ZIP_SHA256 $exeSha256 = $env:EXE_SHA256 $agentVersion = $env:AGENT_VERSION - $tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')" + $tableUrl = $env:ADMIN_CONFIG_URL $tableBody = @{ LatestAgentV2Version = $agentVersion LatestAgentV2Sha256 = $sha256 @@ -636,7 +645,7 @@ jobs: $notes = "$env:RELEASE_NOTES".Trim() if ($notes -eq '') { - $notes = "Minor enhancements and bug fixes. See the [Agent Changelog](https://docs.autopilotmonitor.com/changelog/agent-changelog) for details.`n" + $notes = "Minor enhancements and bug fixes. See the [Agent Changelog]($env:DOCS_BASE_URL/changelog/agent-changelog) for details.`n" } # Previous release for the compare link. Fail-soft: the first release has none, diff --git a/.github/workflows/build-msi.yml b/.github/workflows/build-msi.yml index c13b4cb5..59453a19 100644 --- a/.github/workflows/build-msi.yml +++ b/.github/workflows/build-msi.yml @@ -11,7 +11,7 @@ on: workflow_dispatch: inputs: upload_blob: - description: 'Upload to blob (customer-facing via download.autopilotmonitor.com/agent/)' + description: 'Upload to blob (customer-facing through the download alias)' required: true type: boolean default: true @@ -25,13 +25,13 @@ env: MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1 STABLE_MSI: AutopilotMonitor-Bootstrap.msi - # Authenticode via Azure Key Vault; identity and vault come from repository variables. + # Authenticode via Azure Key Vault; identity and vault come from repository variables, the + # timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here. # CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify 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: # Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed @@ -54,6 +54,11 @@ jobs: with: persist-credentials: false + # Every host in this workflow comes from the one endpoint registry. + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + # 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 @@ -118,7 +123,7 @@ jobs: --timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" ` --file-digest sha256 ` --description 'Autopilot Monitor bootstrap runner' ` - --description-url 'https://www.autopilotmonitor.com' ` + --description-url $env:WWW_BASE_URL ` $env:RUNNER_SCRIPT if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" } @@ -210,6 +215,10 @@ jobs: with: persist-credentials: false + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + # Read from this job's own checkout, never handed over by the build job. - name: Read MSI version from Package.wxs id: version @@ -284,7 +293,7 @@ jobs: --timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" ` --file-digest sha256 ` --description 'Autopilot Monitor Bootstrap' ` - --description-url 'https://www.autopilotmonitor.com' ` + --description-url $env:WWW_BASE_URL ` $env:STABLE_MSI if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" } @@ -305,7 +314,7 @@ jobs: env: MSI_VERSION: ${{ steps.version.outputs.version }} run: | - $containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent" + $containerUrl = $env:AGENT_CONTAINER_URL $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 $version = $env:MSI_VERSION @@ -338,14 +347,14 @@ jobs: shell: pwsh run: | $localSize = (Get-Item $env:STABLE_MSI).Length - $resp = Invoke-WebRequest -Uri "https://download.autopilotmonitor.com/agent/$env:STABLE_MSI" -Method Head -UseBasicParsing + $resp = Invoke-WebRequest -Uri "$env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI" -Method Head -UseBasicParsing if ($resp.StatusCode -ne 200) { Write-Error "Alias HEAD returned $($resp.StatusCode)"; exit 1 } $remoteSize = [long]$resp.Headers['Content-Length'][0] if ($remoteSize -ne $localSize) { Write-Error "Size mismatch: alias serves $remoteSize bytes, local MSI is $localSize bytes" exit 1 } - Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)" + Write-Host "Alias verified: $env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI ($remoteSize bytes)" # 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: diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 09f47ad1..f71c8e88 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -9,10 +9,9 @@ on: permissions: contents: read +# Hosts (API, versions container) come from .github/scripts/Get-Endpoints.ps1. env: FUNCTION_APP: autopilotmonitor-api-eu - HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health - VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj jobs: @@ -35,6 +34,10 @@ jobs: with: persist-credentials: false + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + - name: Login to Azure (OIDC) uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: @@ -50,7 +53,7 @@ jobs: id: version shell: pwsh run: | - $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" + $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "$env:VERSIONS_CONTAINER_URL/backend.txt" $csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw if ($csproj -notmatch '(\d+\.\d+)') { @@ -120,6 +123,10 @@ jobs: with: persist-credentials: false + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + - name: Download publish output uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -162,7 +169,7 @@ jobs: while ([DateTime]::UtcNow -lt $deadline) { $attempt++ try { - $health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30 + $health = Invoke-RestMethod -Uri "$env:API_BASE_URL/api/health" -Method Get -TimeoutSec 30 if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) { Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)" exit 0 @@ -196,7 +203,7 @@ jobs: } | ConvertTo-Json $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 - $url = "${{ env.VERSIONS_CONTAINER }}/backend.json" + $url = "$env:VERSIONS_CONTAINER_URL/backend.json" # no-cache: rotating pointer, must never be served stale by any fronting cache. 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" diff --git a/.github/workflows/deploy-mcp.yml b/.github/workflows/deploy-mcp.yml index 035a3ec8..60b61a66 100644 --- a/.github/workflows/deploy-mcp.yml +++ b/.github/workflows/deploy-mcp.yml @@ -16,9 +16,8 @@ env: # reference — with a sha-only tag `az containerapp update` saw the same image # and silently created no revision (2026-08-28). IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }} - HEALTH_URL: https://mcp.autopilotmonitor.com/health - VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json + # Hosts (MCP, versions container) come from .github/scripts/Get-Endpoints.ps1. jobs: # One job on purpose, unlike the other deploys: all third-party code (npm ci, the build, the @@ -36,6 +35,10 @@ jobs: with: persist-credentials: false + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + # The published customer documentation lives in its own PUBLIC repo and is # baked into the image as the search_docs corpus (see the Dockerfile). No # token is needed. Checking it out here rather than vendoring it keeps the @@ -68,7 +71,7 @@ jobs: id: version shell: pwsh run: | - $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" + $buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "$env:VERSIONS_CONTAINER_URL/mcp.txt" $pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version $prefix = ($pkgVersion -split '\.')[0..1] -join '.' @@ -123,7 +126,7 @@ jobs: while ([DateTime]::UtcNow -lt $deadline) { $attempt++ try { - $health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 60 + $health = Invoke-RestMethod -Uri "$env:MCP_BASE_URL/health" -Method Get -TimeoutSec 60 if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) { Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)" exit 0 @@ -154,7 +157,7 @@ jobs: } | ConvertTo-Json $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 - $url = "${{ env.VERSIONS_CONTAINER }}/mcp.json" + $url = "$env:VERSIONS_CONTAINER_URL/mcp.json" # no-cache: rotating pointer, must never be served stale by any fronting cache. 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" diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index f2b92cf5..ebc790e2 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -28,9 +28,9 @@ on: permissions: contents: read +# Hosts (API, portal, storage) come from .github/scripts/Get-Endpoints.ps1, never from a +# repository variable. env: - PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json - VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions WEB_DIR: src/Web/autopilot-monitor-web # Exact version: `npx --package` resolves from the registry at run time, so the pin is the # only thing that keeps the deploy step's code fixed. Bump deliberately. @@ -52,6 +52,10 @@ jobs: lfs: false fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + # 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. @@ -109,10 +113,6 @@ jobs: NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }} NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }} NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }} - NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} - # Browsers load the live stats through it, and the build reads it once to bake the - # numbers of this moment into the landing page's static HTML (lib/platformStats.ts). - NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }} NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }} # Build stamp for public/version.json (scripts/generate-version.js in prebuild); # the verify step below polls the deployed file for exactly this commit. @@ -122,6 +122,14 @@ jobs: # Star counter from the step above; empty when the lookup failed. NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }} run: | + # Both from the endpoint registry (Load endpoints). Empty, the portal would silently call + # localhost:7071. The stats manifest is what browsers load the live numbers from, and the + # build reads it once to bake the numbers of this moment into the landing page's static + # HTML (lib/platformStats.ts). + export NEXT_PUBLIC_API_BASE_URL="$API_BASE_URL" + export NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL="$STATS_CONTAINER_URL" + test -n "$NEXT_PUBLIC_API_BASE_URL" || { echo "::error::API_BASE_URL is empty - the portal would call localhost"; exit 1; } + test -n "$NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL" || { echo "::error::STATS_CONTAINER_URL is empty"; exit 1; } npm run build test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; } test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; } @@ -200,6 +208,10 @@ jobs: with: persist-credentials: false + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + - name: Download build artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -234,7 +246,7 @@ jobs: while ([DateTime]::UtcNow -lt $deadline) { $attempt++ try { - $v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' } + $v = Invoke-RestMethod -Uri "$env:PORTAL_BASE_URL/version.json" -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' } if ($v.commit -eq $expectedCommit) { Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)" exit 0 @@ -277,7 +289,7 @@ jobs: } | ConvertTo-Json $authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1 - $url = "${{ env.VERSIONS_CONTAINER }}/web.json" + $url = "$env:VERSIONS_CONTAINER_URL/web.json" # no-cache: rotating pointer, must never be served stale by any fronting cache. 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" diff --git a/.github/workflows/indexnow-notify.yml b/.github/workflows/indexnow-notify.yml index 32d15c8a..5206b312 100644 --- a/.github/workflows/indexnow-notify.yml +++ b/.github/workflows/indexnow-notify.yml @@ -3,24 +3,34 @@ name: IndexNow Notify (Bing/Yandex) on: workflow_dispatch: -# The job only talks to public endpoints; it never needs a repository token. -permissions: {} +# Read-only, for the checkout of the endpoint registry; beyond that the job only talks to +# public endpoints. +permissions: + contents: read jobs: notify: runs-on: ubuntu-latest name: Submit URLs to IndexNow steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Load endpoints + shell: pwsh + run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv + - name: Notify IndexNow run: | - URLS=$(curl -s https://www.autopilotmonitor.com/sitemap.xml | grep -oP '\K[^<]+') + URLS=$(curl -s "$WWW_BASE_URL/sitemap.xml" | grep -oP '\K[^<]+') URL_JSON=$(echo "$URLS" | jq -R -s 'split("\n") | map(select(length > 0))') RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "https://api.indexnow.org/indexnow" \ -H "Content-Type: application/json" \ -d "{ - \"host\": \"www.autopilotmonitor.com\", + \"host\": \"${WWW_BASE_URL#https://}\", \"key\": \"8f1710b5-f14b-477e-938b-6c01ef851661\", - \"keyLocation\": \"https://www.autopilotmonitor.com/IndexNow.txt\", + \"keyLocation\": \"$WWW_BASE_URL/IndexNow.txt\", \"urlList\": $URL_JSON }") echo "IndexNow response: HTTP $RESPONSE" diff --git a/.github/workflows/publish-scripts.yml b/.github/workflows/publish-scripts.yml index 982e5893..13591dce 100644 --- a/.github/workflows/publish-scripts.yml +++ b/.github/workflows/publish-scripts.yml @@ -36,13 +36,13 @@ concurrency: # 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. +# drift apart. The publisher takes every host, the timestamp server included, from +# .github/scripts/Get-Endpoints.ps1. 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: # $/ is the self-repository form: this repository at the running commit, never a @@ -124,7 +124,6 @@ jobs: -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