From e4a5a6654d5ad4ab5c78e181fffdd5b8949e3845 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 8 Sep 2026 17:38:14 +0200 Subject: [PATCH] ci: workflows authorize Azure Storage with the federated identity, not SAS secrets (D-222) Every storage write against autopilotmonitoreu -- build-counter CAS, agent ZIP and MSI, version manifests, customer bootstrap scripts, the AdminConfiguration hash oracle -- now rides on the existing federated credential (subject repo:.../ref:refs/heads/main) instead of a long-lived SAS in repo secrets. The REST calls themselves are unchanged: '?' became an Authorization header, so the ETag-CAS of the counters, Content-MD5 and the no-cache contract survive verbatim. That is also why this is not a rewrite onto 'az storage blob upload' -- it cannot express If-Match, and two concurrent builds could then mint the same build number. .github/scripts/Get-StorageAuthHeaders.ps1 is the single place that knows the resource id, the az invocation and the service version. x-ms-version is mandatory for Entra-authorized requests (>= 2017-11-09) and for any Table request without a SAS; 2021-08-06 was verified live against both the blob and the table endpoint of this account. azure/login added to build-agent, build-msi, publish-scripts (plus id-token: write) and deploy-web; deploy-backend and deploy-mcp already had it. Publish-BootstrapScripts.ps1 -DryRun acquires NO token: the PR gate runs it without any Azure login, and that must stay true. The AdminConfiguration merge lost its "secret not configured" skip branch -- a failure there now surfaces instead of silently leaving the hash oracle pointing at an older build. AZURE_BLOB_AGENT_SAS_TOKEN, AZURE_BLOB_VERSIONS_SAS_TOKEN and AZURE_TABLE_ADMIN_SAS_TOKEN are retired once the first runs are green. AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN stays: that account lives in the pre-migration tenant, where this principal has no identity -- it dies with the mirror, not before. Requires on the SP: Storage Blob Data Contributor on the containers 'agent' and 'versions', Storage Table Data Contributor on the table 'AdminConfiguration'. Granted 2026-09-08. --- .github/scripts/Get-StorageAuthHeaders.ps1 | 51 ++++++++++++++++++++ .github/scripts/Publish-BootstrapScripts.ps1 | 29 ++++------- .github/scripts/Request-BuildNumber.ps1 | 19 +++----- .github/workflows/build-agent.yml | 33 +++++++------ .github/workflows/build-msi.yml | 12 ++++- .github/workflows/deploy-backend.yml | 8 +-- .github/workflows/deploy-mcp.yml | 8 +-- .github/workflows/deploy-web.yml | 15 ++++-- .github/workflows/publish-scripts.yml | 13 ++++- infra | 2 +- internal | 2 +- 11 files changed, 131 insertions(+), 61 deletions(-) create mode 100644 .github/scripts/Get-StorageAuthHeaders.ps1 diff --git a/.github/scripts/Get-StorageAuthHeaders.ps1 b/.github/scripts/Get-StorageAuthHeaders.ps1 new file mode 100644 index 00000000..ca42620a --- /dev/null +++ b/.github/scripts/Get-StorageAuthHeaders.ps1 @@ -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' +} diff --git a/.github/scripts/Publish-BootstrapScripts.ps1 b/.github/scripts/Publish-BootstrapScripts.ps1 index 688d0ea0..6c1b1a6f 100644 --- a/.github/scripts/Publish-BootstrapScripts.ps1 +++ b/.github/scripts/Publish-BootstrapScripts.ps1 @@ -31,14 +31,10 @@ lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion. 6. 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. - .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. @@ -50,13 +46,11 @@ ./Publish-BootstrapScripts.ps1 -DryRun .EXAMPLE - ./Publish-BootstrapScripts.ps1 -SasToken $env:SAS -TableSasToken $env:TABLE_SAS + ./Publish-BootstrapScripts.ps1 -LegacySasToken $env:LEGACY_SAS #> [CmdletBinding()] param( - [string]$SasToken, [string]$LegacySasToken, - [string]$TableSasToken, [string]$RepoRoot, [switch]$DryRun ) @@ -66,9 +60,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' @@ -218,7 +209,10 @@ foreach ($extra in $extraSources) { } $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') } foreach ($item in $publishSet) { # Explicit type, not a convenience: an Object[] body is uploaded as space-separated @@ -242,7 +236,7 @@ foreach ($item in $publishSet) { # 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 + 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, @@ -290,7 +284,7 @@ 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 + Invoke-RestMethod -Uri "$ContainerUrl/version.json" -Method Put -Headers ($authHeaders + $manifestHeaders) -Body $body | Out-Null Write-Host "version.json bootstrapVersion -> $scriptVersion (agent fields untouched)" if ($legacySas) { @@ -310,11 +304,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,7 +313,7 @@ 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" } diff --git a/.github/scripts/Request-BuildNumber.ps1 b/.github/scripts/Request-BuildNumber.ps1 index 1b2fa652..a7ab4603 100644 --- a/.github/scripts/Request-BuildNumber.ps1 +++ b/.github/scripts/Request-BuildNumber.ps1 @@ -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 { diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index bbdbf8ec..e1b8ce37 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -67,6 +67,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + # 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 @@ -85,7 +97,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 +107,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 '(\d+\.\d+)') { @@ -224,7 +235,7 @@ jobs: 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 +245,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 +258,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 { @@ -275,35 +285,30 @@ jobs: # (dev render, version-bump guard, bootstrapVersion oracles, alias verification). # Runs AFTER version.json so its bootstrapVersion read sees this build's manifest. ./.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 }}' + -LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' } # 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" } diff --git a/.github/workflows/build-msi.yml b/.github/workflows/build-msi.yml index a0c5b821..cf6f46a6 100644 --- a/.github/workflows/build-msi.yml +++ b/.github/workflows/build-msi.yml @@ -62,6 +62,14 @@ jobs: Write-Host "MSI version (from Package.wxs): $version" "version=$version" >> $env:GITHUB_OUTPUT + # 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.*" @@ -84,7 +92,7 @@ jobs: 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 +110,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" } diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 1338d7e5..0de02b84 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -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 '(\d+\.\d+)') { @@ -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 diff --git a/.github/workflows/deploy-mcp.yml b/.github/workflows/deploy-mcp.yml index f29a18cc..7ca211cd 100644 --- a/.github/workflows/deploy-mcp.yml +++ b/.github/workflows/deploy-mcp.yml @@ -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 diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index d98620ef..386f45b8 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -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 diff --git a/.github/workflows/publish-scripts.yml b/.github/workflows/publish-scripts.yml index 47016900..789b841e 100644 --- a/.github/workflows/publish-scripts.yml +++ b/.github/workflows/publish-scripts.yml @@ -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 @@ -45,12 +46,20 @@ jobs: steps: - uses: actions/checkout@v7 + # 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 }} + - name: Publish bootstrap + customer scripts shell: pwsh 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 }}' ` -DryRun:$dryRun diff --git a/infra b/infra index 2c2687b6..55a8eaf9 160000 --- a/infra +++ b/infra @@ -1 +1 @@ -Subproject commit 2c2687b6abf35ef6580d630b236af403098b8d36 +Subproject commit 55a8eaf9290c3cf1ee574593789d300a89876123 diff --git a/internal b/internal index 41d3f151..702dbb53 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 41d3f15126df53b5ca497336bf1c93e1f5718743 +Subproject commit 702dbb53dca97d5f3e80f0a8dbd8619f273a8de5