diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4629a295..6660c536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,14 @@ on: branches: [master] jobs: + release-provenance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - name: Test production deploy provenance helper + shell: pwsh + run: ./infra/scripts/vercel-deploy-production.test.ps1 + backend: runs-on: ubuntu-latest services: diff --git a/infra/scripts/vercel-deploy-production.core.psm1 b/infra/scripts/vercel-deploy-production.core.psm1 new file mode 100644 index 00000000..1fa456b9 --- /dev/null +++ b/infra/scripts/vercel-deploy-production.core.psm1 @@ -0,0 +1,104 @@ +Set-StrictMode -Version Latest + +function Assert-ReleaseSha { + param( + [Parameter(Mandatory = $true)][string]$SourceSha, + [Parameter(Mandatory = $true)][string]$HeadSha + ) + + if (-not ($SourceSha -cmatch '^[0-9a-f]{40}$')) { + throw 'SourceSha must be a full lowercase 40-character commit SHA.' + } + if ($SourceSha -ne $HeadSha) { + throw "SourceSha must equal the checked-out HEAD ($HeadSha)." + } +} + +function Assert-ReleaseProvenance { + param( + [Parameter(Mandatory = $true)][string]$SourceSha, + [Parameter(Mandatory = $true)][string]$RemoteSha, + [AllowEmptyString()][string]$WorkingTreeStatus + ) + + if (-not [string]::IsNullOrWhiteSpace($WorkingTreeStatus)) { + throw 'Working tree must be clean before a production deployment.' + } + if ($RemoteSha -ne $SourceSha) { + throw "Remote branch must equal SourceSha before deployment (remote: $RemoteSha)." + } +} + +function New-VercelDeployArguments { + param( + [Parameter(Mandatory = $true)][string]$SourceSha, + [Parameter(Mandatory = $true)][string]$Scope, + [Parameter(Mandatory = $true)][string]$Branch + ) + + return @( + 'deploy' + '.' + '--prod' + '--yes' + '--scope' + $Scope + '--build-env' + "BUILD_SHA=$SourceSha" + '--env' + "BUILD_SHA=$SourceSha" + '--meta' + "githubCommitSha=$SourceSha" + '--meta' + "githubCommitRef=$Branch" + ) +} + +function Test-ReleaseHealthResponse { + param( + [Parameter(Mandatory = $true)]$Response, + [Parameter(Mandatory = $true)][string]$ExpectedRevision, + [string]$ExpectedService + ) + + if ($null -eq $Response) { + return $false + } + + $status = $Response.PSObject.Properties['status'] + $revision = $Response.PSObject.Properties['revision'] + if ($null -eq $status -or $status.Value -ne 'ok') { + return $false + } + if ($null -eq $revision -or $revision.Value -ne $ExpectedRevision) { + return $false + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedService)) { + $service = $Response.PSObject.Properties['service'] + if ($null -eq $service -or $service.Value -ne $ExpectedService) { + return $false + } + } + return $true +} + +function Invoke-CheckedNative { + param( + [Parameter(Mandatory = $true)][string]$Command, + [string[]]$Arguments = @() + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Command failed with exit code $LASTEXITCODE." + } +} + +Export-ModuleMember -Function @( + 'Assert-ReleaseSha' + 'Assert-ReleaseProvenance' + 'New-VercelDeployArguments' + 'Test-ReleaseHealthResponse' + 'Invoke-CheckedNative' +) diff --git a/infra/scripts/vercel-deploy-production.ps1 b/infra/scripts/vercel-deploy-production.ps1 index 22cd462a..43c0c9ed 100644 --- a/infra/scripts/vercel-deploy-production.ps1 +++ b/infra/scripts/vercel-deploy-production.ps1 @@ -6,6 +6,7 @@ param( [string]$Scope = $(if ($env:VERCEL_SCOPE) { $env:VERCEL_SCOPE } else { 'nguyensonbmt06-6377s-projects' }), [string]$Branch = 'master', [string]$SourceSha, + [string]$ApiHealthUrl = 'https://foodflow-api-production.up.railway.app/api/healthz', [int]$HealthAttempts = 12, [int]$HealthDelaySeconds = 5, [switch]$PlanOnly @@ -13,6 +14,7 @@ param( $ErrorActionPreference = 'Stop' $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') +Import-Module (Join-Path $PSScriptRoot 'vercel-deploy-production.core.psm1') -Force $targets = @{ admin = @{ @@ -27,76 +29,57 @@ $targets = @{ } } -function Invoke-Native { - param( - [Parameter(Mandatory = $true)][string]$Command, - [Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments - ) - - & $Command @Arguments - if ($LASTEXITCODE -ne 0) { - throw "$Command failed with exit code $LASTEXITCODE." - } -} - Push-Location $repoRoot try { if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw 'Git is required.' } - if (-not (Get-Command vercel -ErrorAction SilentlyContinue)) { - throw 'Vercel CLI is required.' - } - $head = (git rev-parse HEAD).Trim() if ([string]::IsNullOrWhiteSpace($SourceSha)) { $SourceSha = $head } - if ($SourceSha -notmatch '^[0-9a-f]{40}$') { - throw 'SourceSha must be a full lowercase 40-character commit SHA.' - } - if ($SourceSha -ne $head) { - throw "SourceSha must equal the checked-out HEAD ($head)." - } + Assert-ReleaseSha -SourceSha $SourceSha -HeadSha $head $target = $targets[$App] - $deployArguments = @( - 'deploy', - '.', - '--prod', - '--yes', - '--scope', - $Scope, - '--build-env', - "BUILD_SHA=$SourceSha", - '--env', - "BUILD_SHA=$SourceSha", - '--meta', - "githubCommitSha=$SourceSha", - '--meta', - "githubCommitRef=$Branch" - ) + $deployArguments = New-VercelDeployArguments ` + -SourceSha $SourceSha ` + -Scope $Scope ` + -Branch $Branch if ($PlanOnly) { Write-Output "Project: $($target.Project)" Write-Output "Source SHA: $SourceSha" + Write-Output "API health: $ApiHealthUrl" + Write-Output "Required API revision: $SourceSha" Write-Output "Health: $($target.HealthUrl)" Write-Output 'PlanOnly: no Vercel state changed.' - exit 0 + return } - if (-not [string]::IsNullOrWhiteSpace((git status --porcelain))) { - throw 'Working tree must be clean before a production deployment.' + if (-not (Get-Command vercel -ErrorAction SilentlyContinue)) { + throw 'Vercel CLI is required.' } - Invoke-Native git fetch --quiet origin $Branch + Invoke-CheckedNative -Command git -Arguments @('fetch', '--quiet', 'origin', $Branch) $remoteHead = (git rev-parse "origin/$Branch").Trim() - if ($remoteHead -ne $SourceSha) { - throw "origin/$Branch must equal SourceSha before deployment." + Assert-ReleaseProvenance ` + -SourceSha $SourceSha ` + -RemoteSha $remoteHead ` + -WorkingTreeStatus (git status --porcelain | Out-String) + + $apiHealth = Invoke-RestMethod -Uri $ApiHealthUrl -Headers @{ + 'Cache-Control' = 'no-cache' + } -TimeoutSec 20 + if (-not (Test-ReleaseHealthResponse ` + -Response $apiHealth ` + -ExpectedRevision $SourceSha)) { + throw "Railway API health must report SourceSha $SourceSha before Vercel deployment." } - Invoke-Native vercel link --project $target.Project --scope $Scope --yes - Invoke-Native vercel @deployArguments + Invoke-CheckedNative -Command vercel -Arguments @( + 'link', '--project', $target.Project, '--scope', $Scope, '--yes' + ) + Invoke-CheckedNative -Command vercel -Arguments $deployArguments for ($attempt = 1; $attempt -le $HealthAttempts; $attempt++) { try { @@ -104,13 +87,12 @@ try { 'Cache-Control' = 'no-cache' } -TimeoutSec 20 - if ( - $response.status -eq 'ok' -and - $response.service -eq $target.Service -and - $response.revision -eq $SourceSha - ) { + if (Test-ReleaseHealthResponse ` + -Response $response ` + -ExpectedRevision $SourceSha ` + -ExpectedService $target.Service) { Write-Output "Vercel production health verified at $SourceSha." - exit 0 + return } } catch { if ($attempt -eq $HealthAttempts) { diff --git a/infra/scripts/vercel-deploy-production.test.ps1 b/infra/scripts/vercel-deploy-production.test.ps1 new file mode 100644 index 00000000..74d73713 --- /dev/null +++ b/infra/scripts/vercel-deploy-production.test.ps1 @@ -0,0 +1,143 @@ +$ErrorActionPreference = 'Stop' +$modulePath = Join-Path $PSScriptRoot 'vercel-deploy-production.core.psm1' +$scriptPath = Join-Path $PSScriptRoot 'vercel-deploy-production.ps1' +Import-Module $modulePath -Force + +$script:passed = 0 +$script:failed = 0 + +function Invoke-TestCase { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Test + ) + + try { + & $Test + $script:passed += 1 + Write-Output "PASS $Name" + } catch { + $script:failed += 1 + Write-Output "FAIL $Name - $($_.Exception.Message)" + } +} + +function Assert-True { + param([Parameter(Mandatory = $true)][bool]$Value) + if (-not $Value) { + throw 'Expected true.' + } +} + +function Assert-Equal { + param( + [Parameter(Mandatory = $true)]$Actual, + [Parameter(Mandatory = $true)]$Expected + ) + if ($Actual -ne $Expected) { + throw "Expected '$Expected', received '$Actual'." + } +} + +function Assert-Throws { + param([Parameter(Mandatory = $true)][scriptblock]$Action) + try { + & $Action + } catch { + return + } + throw 'Expected the action to throw.' +} + +$sha = 'a' * 40 +$otherSha = 'b' * 40 + +Invoke-TestCase 'accepts an exact lowercase HEAD SHA' { + Assert-ReleaseSha -SourceSha $sha -HeadSha $sha +} + +Invoke-TestCase 'rejects malformed, uppercase, and stale SHAs' { + Assert-Throws { Assert-ReleaseSha -SourceSha 'abc' -HeadSha $sha } + Assert-Throws { Assert-ReleaseSha -SourceSha ('A' * 40) -HeadSha $sha } + Assert-Throws { Assert-ReleaseSha -SourceSha $otherSha -HeadSha $sha } +} + +Invoke-TestCase 'rejects a dirty tree and a stale remote branch' { + Assert-Throws { + Assert-ReleaseProvenance ` + -SourceSha $sha ` + -RemoteSha $sha ` + -WorkingTreeStatus ' M tracked-file' + } + Assert-Throws { + Assert-ReleaseProvenance ` + -SourceSha $sha ` + -RemoteSha $otherSha ` + -WorkingTreeStatus '' + } +} + +Invoke-TestCase 'builds immutable Vercel arguments without losing values' { + $arguments = @(New-VercelDeployArguments ` + -SourceSha $sha ` + -Scope 'scope-name' ` + -Branch 'master') + Assert-True ($arguments -contains "BUILD_SHA=$sha") + Assert-True ($arguments -contains "githubCommitSha=$sha") + Assert-True ($arguments -contains 'githubCommitRef=master') + Assert-Equal ($arguments[0]) 'deploy' +} + +Invoke-TestCase 'accepts only the expected status, service, and revision' { + $valid = [pscustomobject]@{ + status = 'ok' + service = 'foodflow-admin' + revision = $sha + } + Assert-True (Test-ReleaseHealthResponse ` + -Response $valid ` + -ExpectedRevision $sha ` + -ExpectedService 'foodflow-admin') + Assert-True (-not (Test-ReleaseHealthResponse ` + -Response $valid ` + -ExpectedRevision $otherSha ` + -ExpectedService 'foodflow-admin')) + Assert-True (-not (Test-ReleaseHealthResponse ` + -Response $valid ` + -ExpectedRevision $sha ` + -ExpectedService 'foodflow-restaurant')) + Assert-True (-not (Test-ReleaseHealthResponse ` + -Response ([pscustomobject]@{ status = 'ok' }) ` + -ExpectedRevision $sha)) + Assert-True (-not (Test-ReleaseHealthResponse ` + -Response ([pscustomobject]@{ status = 'ok'; revision = $sha }) ` + -ExpectedRevision $sha ` + -ExpectedService 'foodflow-admin')) +} + +Invoke-TestCase 'propagates a native command failure' { + $shell = if ($env:OS -eq 'Windows_NT') { + (Get-Command powershell.exe -ErrorAction Stop).Source + } else { + (Get-Command pwsh -ErrorAction Stop).Source + } + Assert-Throws { + Invoke-CheckedNative ` + -Command $shell ` + -Arguments @('-NoProfile', '-Command', 'exit 7') + } +} + +Invoke-TestCase 'prints both application plans without contacting Vercel' { + $head = (git -C (Join-Path $PSScriptRoot '..\..') rev-parse HEAD).Trim() + $adminPlan = & $scriptPath -App admin -SourceSha $head -PlanOnly | Out-String + $restaurantPlan = & $scriptPath -App restaurant -SourceSha $head -PlanOnly | Out-String + Assert-True ($adminPlan -match 'PlanOnly: no Vercel state changed') + Assert-True ($restaurantPlan -match 'PlanOnly: no Vercel state changed') + Assert-True ($adminPlan -match 'Required API revision') +} + +Write-Output "RESULT passed=$script:passed failed=$script:failed" +if ($script:failed -gt 0) { + exit 1 +}