From a4923cce33fa09e84a18177b8e6d186c1d50ed71 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 17 Sep 2026 09:54:45 +0200 Subject: [PATCH] RDoc-4100 Make CloudFront function code an update/publish a build step Fix the CSP push, which aborted the first staging deploy before writing. get-response-headers-policy returns `{}` for headers that are not configured, and update-response-headers-policy refuses that: once XSSProtection, FrameOptions or ReferrerPolicy appears in the payload it requires its own fields. The read shape is not a valid write shape, so empty objects are now dropped before the config goes back. Stripping happens before the pre-write snapshot and again on the post-write re-read, so both sides of every diff are comparable and the one-field guarantee still holds. Empty arrays are left alone, since {Quantity: 0, Items: []} is valid inbound. The strip returns arrays comma-prefixed: unrolled, a one-element array would collapse to a scalar and turn {Items: ["x"]} into {Items: "x"}. The guard cannot catch that, because it compares post-strip against post-strip. A config-sync failure no longer strands the content that phases 1 and 2 already uploaded. The error is held and rethrown after the invalidation and the stale asset cleanup, so the build still fails but the edge stops serving the previous release from cache. --- scripts/deploy.ps1 | 12 +++++++++++- scripts/lib/cloudfront-common.ps1 | 30 ++++++++++++++++++++++++++++++ scripts/sync-csp.ps1 | 5 +++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 7c91c25e73..e80b825ea3 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -257,8 +257,16 @@ if ($DryRun) { Write-Host "Updating CloudFront KeyValueStore" -ForegroundColor Cyan Update-CloudFrontKVS + # A config-sync failure must not strand the content phases 1 and 2 already uploaded, so the + # error is held and rethrown after the invalidation and cleanup. The build still goes red. Write-Host 'Syncing CloudFront configuration' -ForegroundColor Cyan - Sync-CloudFrontConfig + $configError = $null + try { + Sync-CloudFrontConfig + } catch { + $configError = $_ + Write-Host " CloudFront config sync failed: $_" -ForegroundColor Red + } if ($CloudFrontDistributionId) { Write-Host "Invalidating CloudFront distribution $CloudFrontDistributionId" -ForegroundColor Cyan @@ -276,6 +284,8 @@ if ($DryRun) { --delete if ($LASTEXITCODE) { throw 'aws s3 sync (hashed assets – cleanup) failed' } + if ($configError) { throw $configError } + } Write-Host 'Deployment completed successfully.' -ForegroundColor Green diff --git a/scripts/lib/cloudfront-common.ps1 b/scripts/lib/cloudfront-common.ps1 index bf50666791..cd682b4c59 100644 --- a/scripts/lib/cloudfront-common.ps1 +++ b/scripts/lib/cloudfront-common.ps1 @@ -35,6 +35,36 @@ function Invoke-CloudFrontInvalidation { if ($LASTEXITCODE) { throw 'CloudFront invalidation failed' } } +# get-response-headers-policy returns `{}` for headers that are not configured, but +# update-response-headers-policy refuses them: once XSSProtection, FrameOptions or ReferrerPolicy +# appears in the payload it requires its own fields. The read shape is therefore not a valid write +# shape, so empty objects are dropped before the config goes back. Empty arrays are left alone, +# since {Quantity: 0, Items: []} is valid on the way in. +function Remove-EmptyObjects { + param([AllowNull()] $Node) + + if ($null -eq $Node) { return $null } + + if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) { + $items = [System.Collections.ArrayList]::new() + foreach ($item in $Node) { [void]$items.Add((Remove-EmptyObjects $item)) } + # Comma-prefix keeps a one-element array an array; returning it bare would unroll it to a + # scalar and turn {Items: ["x"]} into {Items: "x"}, which CloudFront rejects. + return , $items.ToArray() + } + + if ($Node -isnot [System.Management.Automation.PSCustomObject]) { return $Node } + + $kept = [ordered]@{} + foreach ($property in $Node.PSObject.Properties) { + $value = Remove-EmptyObjects $property.Value + $isEmptyObject = $value -is [System.Management.Automation.PSCustomObject] -and + @($value.PSObject.Properties).Count -eq 0 + if (-not $isEmptyObject) { $kept[$property.Name] = $value } + } + return [pscustomobject]$kept +} + # Paths where $Candidate differs from $Reference: node kind, array length, added or dropped # property, or scalar value. Proves a patch touched only the field it meant to. function Get-JsonDifference { diff --git a/scripts/sync-csp.ps1 b/scripts/sync-csp.ps1 index 4492eaded2..b441f00670 100644 --- a/scripts/sync-csp.ps1 +++ b/scripts/sync-csp.ps1 @@ -50,7 +50,7 @@ $current = aws cloudfront get-response-headers-policy --id $ResponseHeadersPolic if ($LASTEXITCODE) { throw 'aws get-response-headers-policy failed' } $etag = $current.ETag -$config = $current.ResponseHeadersPolicy.ResponseHeadersPolicyConfig +$config = Remove-EmptyObjects $current.ResponseHeadersPolicy.ResponseHeadersPolicyConfig if (Compare-CloudFrontValue -Live $config.SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy -Local $csp -Noun 'policy') { return } @@ -105,7 +105,8 @@ try { $reread = aws cloudfront get-response-headers-policy --id $ResponseHeadersPolicyId | ConvertFrom-Json if ($LASTEXITCODE) { throw 'aws get-response-headers-policy failed on re-read' } -$now = $reread.ResponseHeadersPolicy.ResponseHeadersPolicyConfig | ConvertTo-Json -Depth 30 -Compress | ConvertFrom-Json +$now = Remove-EmptyObjects $reread.ResponseHeadersPolicy.ResponseHeadersPolicyConfig | + ConvertTo-Json -Depth 30 -Compress | ConvertFrom-Json $postDiffs = @(Get-JsonDifference -Reference $before -Candidate $now | Where-Object { $_ -notlike "$CspPath*" }) if ($postDiffs.Count) { throw "The policy changed outside the CSP: $($postDiffs -join '; '). Restore the affected headers from the CloudFront console."