Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
104 changes: 104 additions & 0 deletions infra/scripts/vercel-deploy-production.core.psm1
Original file line number Diff line number Diff line change
@@ -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'
)
86 changes: 34 additions & 52 deletions infra/scripts/vercel-deploy-production.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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
)

$ErrorActionPreference = 'Stop'
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
Import-Module (Join-Path $PSScriptRoot 'vercel-deploy-production.core.psm1') -Force

$targets = @{
admin = @{
Expand All @@ -27,90 +29,70 @@ $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 {
$response = Invoke-RestMethod -Uri $target.HealthUrl -Headers @{
'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) {
Expand Down
Loading
Loading