2026-05-26 08:30:08 +12:00
[ CmdletBinding ( ) ]
2026-05-02 12:39:55 +12:00
param (
[ switch ] $Force ,
2026-05-02 19:44:45 +12:00
[ switch ] $SkipSiteCheck ,
2026-05-18 22:25:43 +12:00
[ string ] $Service ,
# When set, the next mail-api boot copies admin state (client_profiles,
# allowed_emails, drafts) from the on-disk JSON files into the shared
# postgres database, overwriting anything currently in admin_kv. After the
# deploy completes the flag is automatically reset to 'auto' for subsequent
# boots so we don't keep overwriting live data.
2026-05-26 08:30:08 +12:00
[ switch ] $SeedAdminData ,
# Skip regenerating the legacy-clients-seed.json file. By default every
# deploy refreshes it from data/legacy-{clients,onboarding,contracts}.json.
# The mail-api merges this on boot, add-only — it never clobbers live data.
[ switch ] $SkipLegacySeed
2026-05-02 12:39:55 +12:00
)
2026-05-26 08:30:08 +12:00
# ---------------------------------------------------------------------------
# UI helpers — colored output, step headers, timing
# ---------------------------------------------------------------------------
$script:UseColor = $Host . UI . SupportsVirtualTerminal -or [ bool ] $env:WT_SESSION -or [ bool ] $env:TERM_PROGRAM
$script:ESC = [ char ] 27
# Goodwalk brand palette + status colors (24-bit ANSI where supported)
$script:CGreen = '38;2;122;170;122' # softened Goodwalk green for legibility on dark terminals
$script:CYellow = '38;2;255;209;0' # #FFD100
$script:CCyan = '96'
$script:CDim = '90'
$script:CGrey = '37'
$script:COk = '92'
$script:CWarn = '93'
$script:CErr = '91'
function Format-Color {
param ( [ string ] $Text , [ string ] $Code )
if ( -not $script:UseColor ) { return $Text }
return " $script:ESC [ ${Code} m $Text $script:ESC [0m "
}
function Show-DeployBanner {
$border = '──────────────────────────────────────────────────────' # 54 chars
$pad22 = ' ' * 22
Write-Host ''
Write-Host ( Format-Color " ╭ $border ╮ " $script:CGreen )
Write-Host -NoNewline ( Format-Color ' │' $script:CGreen )
Write-Host -NoNewline ( Format-Color ' GoodWalk' $script:CYellow )
Write-Host -NoNewline ( Format-Color ' · production deploy' $script:CDim )
Write-Host ( Format-Color ( $pad22 + '│' ) $script:CGreen )
Write-Host ( Format-Color " ╰ $border ╯ " $script:CGreen )
Write-Host ''
}
function Write-Info {
param ( [ string ] $Text )
Write-Host ( Format-Color " $Text " $script:CDim )
}
function Write-Note {
param ( [ string ] $Text )
Write-Host ( " " + ( Format-Color '·' $script:CYellow ) + " " + ( Format-Color $Text $script:CGrey ) )
}
function Write-Field {
param ( [ string ] $Label , [ string ] $Value )
$padded = $Label . PadRight ( 22 )
Write-Host -NoNewline ( Format-Color " $padded " $script:CDim )
Write-Host ( Format-Color $Value $script:CGrey )
}
function Write-Section {
param ( [ string ] $Text )
Write-Host ''
Write-Host ( Format-Color " ── $Text " $script:CCyan )
}
function Write-StepHeader {
param ( [ string ] $Text )
Write-Host ''
Write-Host -NoNewline ( Format-Color '▶ ' $script:CCyan )
Write-Host ( Format-Color $Text $script:CGrey )
}
function Write-Ok {
param ( [ string ] $Text , [ double ] $Sec = -1 )
$suffix = if ( $Sec -ge 0 ) { Format-Color ( ' ({0:N1}s)' -f $Sec ) $script:CDim } else { '' }
Write-Host ( " " + ( Format-Color '✓' $script:COk ) + " " + ( Format-Color $Text $script:CGrey ) + $suffix )
}
function Write-Fail {
param ( [ string ] $Text )
Write-Host ( " " + ( Format-Color '✗' $script:CErr ) + " " + ( Format-Color $Text $script:CErr ) )
}
function Write-WarnLine {
param ( [ string ] $Text )
Write-Host ( " " + ( Format-Color '!' $script:CWarn ) + " " + ( Format-Color $Text $script:CWarn ) )
}
2026-05-02 12:39:55 +12:00
# ---------------------------------------------------------------------------
# Goodwalk production deployment settings
# Update these values before the first real deployment.
# This script targets the main Svelte Goodwalk stack that uses the top-level
# docker-compose.yml. It does not touch the legacy WordPress compose files.
# ---------------------------------------------------------------------------
$SshUser = 'root'
$ServerHost = 'gw-prod'
# Leave blank to use interactive password prompts instead of an SSH key.
$SshKeyPath = ''
$LocalProjectPath = Split-Path -Parent $MyInvocation . MyCommand . Path
$SshConfigPath = Join-Path $LocalProjectPath 'ssh-config'
$RemoteDeploymentPath = '/docker/goodwalk-svelte'
$ComposeFileName = 'docker-compose.prod.yml'
$DockerProjectName = 'goodwalk-svelte'
2026-05-03 11:16:53 +12:00
$NginxConfigSource = 'nginx/goodwalk.co.nz.svelte.conf.example'
$NginxConfigTarget = '/docker/nginx/conf.d/goodwalk.co.nz.conf'
$NginxComposeFile = '/docker/nginx/docker-compose.yml'
$NginxProjectName = 'nginx'
2026-05-05 20:54:56 +12:00
# Host paths used for the maintenance page. The directory must be bind-mounted
# into the shared nginx container at /var/www/maintenance:ro (see DEPLOYMENT.md).
# The flag file lives in the existing conf.d bind mount; nginx ignores non-.conf
# files, so it does not pollute the include glob.
$MaintenanceHostDir = '/docker/nginx/maintenance'
$MaintenanceFlagPath = '/docker/nginx/conf.d/maintenance.flag'
2026-05-02 12:39:55 +12:00
# Optional deployment settings.
$VerifyUrl = 'https://www.goodwalk.co.nz/api/health'
$RemoteArchivePath = '/tmp/goodwalk-deploy.tgz'
$RemoteHelperPath = '/tmp/goodwalk-deploy-remote.sh'
$LocalRemoteHelperPath = Join-Path $LocalProjectPath 'scripts\deploy-remote.sh'
2026-05-02 19:44:45 +12:00
$GeneratedHomepageContentPath = Join-Path $LocalProjectPath 'deploy-data\homepage-content.json'
2026-05-02 12:39:55 +12:00
function Assert-NotBlank {
param (
[ string ] $Name ,
[ string ] $Value
)
if ( [ string ] :: IsNullOrWhiteSpace ( $Value ) ) {
throw " Required setting ' $Name ' is blank. Update deploy.ps1 before running it. "
}
}
function Assert-Command {
param ( [ string ] $Name )
if ( -not ( Get-Command $Name -ErrorAction SilentlyContinue ) ) {
throw " Required command ' $Name ' is not available in PATH. "
}
}
function Invoke-External {
param (
[ string ] $FilePath ,
[ string[] ] $Arguments
)
& $FilePath @Arguments
if ( $LASTEXITCODE -ne 0 ) {
throw " Command failed: $FilePath $( $Arguments -join ' ' ) "
}
}
function Get-SshArgumentList {
$args = @ ( )
if ( -not [ string ] :: IsNullOrWhiteSpace ( $SshConfigPath ) ) {
$args + = @ ( '-F' , $SshConfigPath )
}
if ( -not [ string ] :: IsNullOrWhiteSpace ( $SshKeyPath ) ) {
$args + = @ ( '-i' , $SshKeyPath )
}
return $args
}
function New-DeployArchive {
param (
[ string ] $ProjectPath
)
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$archivePath = Join-Path ( [ System.IO.Path ] :: GetTempPath ( ) ) " goodwalk-deploy- $timestamp .tgz "
$excludeArgs = @ (
'--exclude=.git' ,
'--exclude=node_modules' ,
'--exclude=.svelte-kit' ,
'--exclude=build' ,
'--exclude=.env' ,
'--exclude=.env.*' ,
'--exclude=logs' ,
'--exclude=mail-api/__pycache__' ,
'--exclude=mail-api/*.pyc' ,
'--exclude=migration-backups'
)
if ( Test-Path $archivePath ) {
Remove-Item -LiteralPath $archivePath -Force
}
Push-Location $ProjectPath
try {
$tarArguments = @ ( '-czf' , $archivePath ) + $excludeArgs + @ ( '.' )
Invoke-External -FilePath 'tar' -Arguments $tarArguments
}
finally {
Pop-Location
}
return $archivePath
}
2026-05-02 19:44:45 +12:00
function Export-HomepageContent {
param (
[ string ] $ProjectPath ,
[ string ] $OutputPath
)
$scriptPath = Join-Path $ProjectPath 'scripts\export-homepage-content.mjs'
if ( -not ( Test-Path -LiteralPath $scriptPath ) ) {
throw " Homepage content export script not found: $scriptPath "
}
2026-05-12 00:45:02 +12:00
$resolverPath = Join-Path $ProjectPath 'scripts\sveltekit-resolver.mjs'
2026-05-02 19:44:45 +12:00
Push-Location $ProjectPath
try {
Invoke-External -FilePath 'node' -Arguments @ (
'--experimental-strip-types' ,
2026-05-12 00:45:02 +12:00
" --import= $( ( [ uri ] :: new ( [ System.IO.Path ] :: GetFullPath ( $resolverPath ) ) ) . AbsoluteUri ) " ,
2026-05-02 19:44:45 +12:00
$scriptPath ,
$OutputPath
)
}
finally {
Pop-Location
}
}
2026-05-26 08:30:08 +12:00
function Build-LegacySeed {
param (
[ string ] $ProjectPath
)
$scriptPath = Join-Path $ProjectPath 'scripts\build-legacy-seed.mjs'
if ( -not ( Test-Path -LiteralPath $scriptPath ) ) {
throw " Legacy seed builder not found: $scriptPath "
}
Push-Location $ProjectPath
try {
Invoke-External -FilePath 'node' -Arguments @ ( $scriptPath )
}
finally {
Pop-Location
}
$outputPath = Join-Path $ProjectPath 'mail-api\legacy-clients-seed.json'
if ( -not ( Test-Path -LiteralPath $outputPath ) ) {
throw " Legacy seed builder did not produce expected output: $outputPath "
}
return $outputPath
}
2026-05-11 21:02:24 +12:00
function New-UnixScriptCopy {
param (
[ string ] $SourcePath
)
if ( -not ( Test-Path -LiteralPath $SourcePath ) ) {
throw " Script not found: $SourcePath "
}
$tempFileName = 'goodwalk-deploy-helper-{0}.sh' -f ( [ System.Guid ] :: NewGuid ( ) . ToString ( 'N' ) )
$tempPath = Join-Path ( [ System.IO.Path ] :: GetTempPath ( ) ) $tempFileName
$content = [ System.IO.File ] :: ReadAllText ( $SourcePath )
$normalized = $content . Replace ( " `r `n " , " `n " ) . Replace ( " `r " , " `n " )
$utf8NoBom = New-Object System . Text . UTF8Encoding ( $false )
[ System.IO.File ] :: WriteAllText ( $tempPath , $normalized , $utf8NoBom )
return $tempPath
}
2026-05-02 12:39:55 +12:00
function Invoke-SiteCheck {
param ( [ string ] $Url )
2026-05-26 08:30:08 +12:00
Write-StepHeader 'Production site check'
Write-Info $Url
2026-05-02 12:39:55 +12:00
try {
$response = Invoke-WebRequest -Uri $Url -MaximumRedirection 5 -TimeoutSec 30
2026-05-19 23:36:58 +12:00
if ( $response . StatusCode -ge 200 -and $response . StatusCode -lt 400 ) {
2026-05-26 08:30:08 +12:00
Write-Ok " HTTP $( $response . StatusCode ) from production "
2026-05-19 23:36:58 +12:00
return
}
throw " Unexpected HTTP $( $response . StatusCode ) from $Url "
2026-05-02 12:39:55 +12:00
}
catch {
2026-05-19 23:36:58 +12:00
$message = " Post-deploy site check failed: $( $_ . Exception . Message ) . Verify URL: $Url "
if ( $SkipSiteCheck ) {
2026-05-26 08:30:08 +12:00
Write-WarnLine $message
2026-05-19 23:36:58 +12:00
} else {
2026-05-26 08:30:08 +12:00
Write-Fail $message
2026-05-19 23:36:58 +12:00
throw $message
}
2026-05-02 12:39:55 +12:00
}
}
2026-05-26 08:30:08 +12:00
function Get-RemoteSmokeSecret {
param (
[ string ] $SshTarget ,
[ string[] ] $SshArgList ,
[ string ] $DeployPath
)
# Pulls DEPLOY_SMOKE_SECRET from the live .env that was just merged by the
# deploy. Single source of truth — no need to sync template and live.
$remoteCmd = " grep -E '^DEPLOY_SMOKE_SECRET=' ' $DeployPath /.env' | tail -n1 | cut -d= -f2- "
$output = & ssh @SshArgList $SshTarget $remoteCmd 2 > $null
if ( $LASTEXITCODE -ne 0 ) { return $null }
if ( [ string ] :: IsNullOrWhiteSpace ( $output ) ) { return $null }
return ( $output -join '' ) . Trim ( )
}
function Invoke-FormSmokeTest {
param (
[ string ] $Url ,
[ string ] $Secret ,
[ hashtable ] $Payload ,
[ string ] $Label
)
$json = $Payload | ConvertTo-Json -Compress -Depth 6
$headers = @ {
'X-Deploy-Smoke' = $Secret
'Content-Type' = 'application/json'
'User-Agent' = 'goodwalk-deploy-smoke/1.0'
}
try {
$response = Invoke-WebRequest -Uri $Url -Method Post -Headers $headers -Body $json -TimeoutSec 30 -MaximumRedirection 0
}
catch {
throw " $Label smoke test failed: $( $_ . Exception . Message ) "
}
if ( $response . StatusCode -ne 200 ) {
throw " $Label smoke test returned HTTP $( $response . StatusCode ) (expected 200) "
}
$body = $null
try { $body = $response . Content | ConvertFrom-Json } catch { }
if ( -not $body -or -not $body . ok -or -not $body . smoke ) {
throw " $Label smoke test response did not include ok=true,smoke=true. Body: $( $response . Content ) "
}
Write-Ok " $Label OK (request_id= $( $body . request_id ) ) "
}
function Invoke-AllFormSmokeTests {
param (
[ string ] $SshTarget ,
[ string[] ] $SshArgList ,
[ string ] $DeployPath
)
Write-StepHeader 'Production form smoke tests'
$secret = Get-RemoteSmokeSecret -SshTarget $SshTarget -SshArgList $SshArgList -DeployPath $DeployPath
if ( [ string ] :: IsNullOrWhiteSpace ( $secret ) ) {
$msg = " DEPLOY_SMOKE_SECRET not found in $DeployPath /.env on the server. Smoke tests cannot run. "
if ( $SkipSiteCheck ) {
Write-WarnLine $msg
return
}
Write-Fail $msg
throw $msg
}
# Payloads carry only the fields each Pydantic model requires. Validation,
# rate limiting, honeypot, email send and DB writes are all bypassed by the
# mail-api smoke shortcut, but Pydantic still parses the body — so this also
# catches schema breakage.
$smokeEmail = 'deploy-smoke@goodwalk.co.nz'
$bookingPayload = @ {
fullName = 'Deploy Smoke'
email = $smokeEmail
phone = '+64-00-000-0000'
enquiryType = 'booking'
page = '/deploy-smoke'
}
$onboardingPayload = @ {
fullName = 'Deploy Smoke'
email = $smokeEmail
phone = '+64-00-000-0000'
address = '1 Smoke St, Auckland'
dogName = 'Smoke'
dogBreed = 'Test'
vetName = 'Dr Smoke'
vetPhone = '+64-00-000-0000'
emergencyContactName = 'Smoke Contact'
emergencyContactPhone = '+64-00-000-0000'
signatureDataUrl = 'data:image/png;base64,iVBORw0KGgo='
page = '/deploy-smoke'
}
$contractPayload = @ {
fullName = 'Deploy Smoke'
email = $smokeEmail
phone = '+64-00-000-0000'
address = '1 Smoke St, Auckland'
dogName = 'Smoke'
dogBreed = 'Test'
serviceType = 'pack-walks'
startDate = '2099-01-01'
signatureDataUrl = 'data:image/png;base64,iVBORw0KGgo='
page = '/deploy-smoke'
}
# Each endpoint is exercised on the subdomain that actually hosts it
# in production (see nginx/goodwalk.co.nz.svelte.conf.example):
# /api/submit → www.goodwalk.co.nz
# /api/onboarding-submit → clients.goodwalk.co.nz
# /api/contract-submit → clients.goodwalk.co.nz
Invoke-FormSmokeTest -Url 'https://www.goodwalk.co.nz/api/submit' -Secret $secret -Payload $bookingPayload -Label 'booking form (www /api/submit)'
Invoke-FormSmokeTest -Url 'https://clients.goodwalk.co.nz/api/onboarding-submit' -Secret $secret -Payload $onboardingPayload -Label 'onboarding form (clients /api/onboarding-submit)'
Invoke-FormSmokeTest -Url 'https://clients.goodwalk.co.nz/api/contract-submit' -Secret $secret -Payload $contractPayload -Label 'contract form (clients /api/contract-submit)'
}
Show-DeployBanner
2026-05-02 12:39:55 +12:00
Assert-Command ssh
Assert-Command scp
Assert-Command tar
2026-05-02 19:44:45 +12:00
Assert-Command node
2026-05-02 12:39:55 +12:00
Assert-NotBlank -Name 'SshUser' -Value $SshUser
Assert-NotBlank -Name 'ServerHost' -Value $ServerHost
Assert-NotBlank -Name 'LocalProjectPath' -Value $LocalProjectPath
Assert-NotBlank -Name 'RemoteDeploymentPath' -Value $RemoteDeploymentPath
Assert-NotBlank -Name 'ComposeFileName' -Value $ComposeFileName
Assert-NotBlank -Name 'DockerProjectName' -Value $DockerProjectName
2026-05-03 11:16:53 +12:00
Assert-NotBlank -Name 'NginxConfigSource' -Value $NginxConfigSource
Assert-NotBlank -Name 'NginxConfigTarget' -Value $NginxConfigTarget
Assert-NotBlank -Name 'NginxComposeFile' -Value $NginxComposeFile
Assert-NotBlank -Name 'NginxProjectName' -Value $NginxProjectName
2026-05-05 20:54:56 +12:00
Assert-NotBlank -Name 'MaintenanceHostDir' -Value $MaintenanceHostDir
Assert-NotBlank -Name 'MaintenanceFlagPath' -Value $MaintenanceFlagPath
2026-05-02 12:39:55 +12:00
2026-05-02 19:44:45 +12:00
if ( -not [ string ] :: IsNullOrWhiteSpace ( $Service ) ) {
$Service = $Service . Trim ( )
}
2026-05-02 12:39:55 +12:00
if ( -not [ string ] :: IsNullOrWhiteSpace ( $SshConfigPath ) -and -not ( Test-Path -LiteralPath $SshConfigPath ) ) {
throw " SSH config file not found: $SshConfigPath "
}
if ( -not [ string ] :: IsNullOrWhiteSpace ( $SshKeyPath ) -and -not ( Test-Path -LiteralPath $SshKeyPath ) ) {
throw " SSH key not found: $SshKeyPath "
}
if ( -not ( Test-Path -LiteralPath $LocalProjectPath ) ) {
throw " Local project path not found: $LocalProjectPath "
}
if ( -not ( Test-Path -LiteralPath ( Join-Path $LocalProjectPath $ComposeFileName ) ) ) {
throw " Compose file not found in local project path: $( Join-Path $LocalProjectPath $ComposeFileName ) "
}
if ( -not ( Test-Path -LiteralPath $LocalRemoteHelperPath ) ) {
throw " Remote deployment helper not found: $LocalRemoteHelperPath "
}
$sshTarget = '{0}@{1}' -f $SshUser , $ServerHost
$scpArchiveTarget = '{0}:{1}' -f $sshTarget , $RemoteArchivePath
$scpHelperTarget = '{0}:{1}' -f $sshTarget , $RemoteHelperPath
$sshArgs = Get-SshArgumentList
2026-05-26 08:30:08 +12:00
Write-Section 'Configuration'
Write-Field 'Local project' $LocalProjectPath
Write-Field 'Remote path' $RemoteDeploymentPath
Write-Field 'Compose file' $ComposeFileName
Write-Field 'Docker project' $DockerProjectName
Write-Field 'Shared nginx conf' $NginxConfigTarget
Write-Field 'Shared nginx compose' $NginxComposeFile
Write-Field 'Maintenance dir' " $MaintenanceHostDir (mounted at /var/www/maintenance:ro) "
Write-Field 'Maintenance flag' $MaintenanceFlagPath
Write-Field 'SSH target' $sshTarget
Write-Field 'SSH config' $SshConfigPath
2026-05-02 19:44:45 +12:00
if ( -not [ string ] :: IsNullOrWhiteSpace ( $Service ) ) {
2026-05-26 08:30:08 +12:00
Write-Field 'Target service' $Service
2026-05-02 19:44:45 +12:00
}
2026-05-18 22:25:43 +12:00
if ( $SeedAdminData ) {
2026-05-26 08:30:08 +12:00
Write-Field 'Admin data' 'seed postgres from JSON on next mail-api boot'
}
if ( $SkipLegacySeed ) {
Write-Field 'Legacy clients seed' 'SKIPPED (existing mail-api/legacy-clients-seed.json reused)'
} else {
Write-Field 'Legacy clients seed' 'rebuild from data/legacy-*.json (add-only merge on boot)'
2026-05-18 22:25:43 +12:00
}
2026-05-02 12:39:55 +12:00
if ( [ string ] :: IsNullOrWhiteSpace ( $SshKeyPath ) ) {
2026-05-26 08:30:08 +12:00
Write-Field 'SSH auth' 'interactive password prompt'
2026-05-02 12:39:55 +12:00
} else {
2026-05-26 08:30:08 +12:00
Write-Field 'SSH auth' " key file $SshKeyPath "
}
Write-Section 'Safety notes'
Write-Note 'Only the top-level Goodwalk compose project will be updated.'
Write-Note 'Legacy WordPress/onboarding compose files are not used.'
Write-Note 'Remote .env files are preserved because they are not uploaded.'
Write-Note 'No global Docker prune/stop/delete commands are used.'
Write-Note 'Shared nginx will be updated and reloaded with the Docker-DNS-based config.'
Write-Section 'Subdomains served by this stack'
Write-Note 'goodwalk.co.nz / www.goodwalk.co.nz — marketing'
Write-Note 'clients.goodwalk.co.nz — client onboarding + contracts'
Write-Note 'cp.goodwalk.co.nz — owner admin dashboard'
Write-Note 'onboarding/admin remain legacy redirect aliases'
2026-05-18 22:25:43 +12:00
if ( $SeedAdminData ) {
2026-05-26 08:30:08 +12:00
Write-Host ''
Write-WarnLine 'Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.'
2026-05-18 22:25:43 +12:00
}
2026-05-02 12:39:55 +12:00
if ( -not $Force ) {
2026-05-26 08:30:08 +12:00
Write-Host ''
Write-Host -NoNewline ( " " + ( Format-Color '?' $script:CYellow ) + " " )
Write-Host -NoNewline ( Format-Color 'Type ' $script:CGrey )
Write-Host -NoNewline ( Format-Color 'DEPLOY' $script:CYellow )
Write-Host -NoNewline ( Format-Color " to continue with remote path ' " $script:CGrey )
Write-Host -NoNewline ( Format-Color $RemoteDeploymentPath $script:CCyan )
Write-Host -NoNewline ( Format-Color " ' " $script:CGrey )
$confirmation = Read-Host
2026-05-02 12:39:55 +12:00
if ( $confirmation -ne 'DEPLOY' ) {
2026-05-26 08:30:08 +12:00
Write-Fail 'Deployment cancelled.'
2026-05-02 12:39:55 +12:00
throw 'Deployment cancelled.'
}
}
$archivePath = $null
2026-05-11 21:02:24 +12:00
$uploadHelperPath = $null
2026-05-26 08:30:08 +12:00
$totalWatch = [ System.Diagnostics.Stopwatch ] :: StartNew ( )
$stepWatch = [ System.Diagnostics.Stopwatch ] :: new ( )
2026-05-02 12:39:55 +12:00
try {
2026-05-26 08:30:08 +12:00
Write-StepHeader 'Export homepage content for PostgreSQL sync'
$stepWatch . Restart ( )
2026-05-02 19:44:45 +12:00
Export-HomepageContent -ProjectPath $LocalProjectPath -OutputPath $GeneratedHomepageContentPath
2026-05-26 08:30:08 +12:00
Write-Ok 'Homepage content exported' $stepWatch . Elapsed . TotalSeconds
if ( -not $SkipLegacySeed ) {
Write-StepHeader 'Build legacy clients seed for mail-api image'
$stepWatch . Restart ( )
$legacySeedPath = Build-LegacySeed -ProjectPath $LocalProjectPath
Write-Info " Seed file: $legacySeedPath "
Write-Ok 'Legacy clients seed ready' $stepWatch . Elapsed . TotalSeconds
} else {
Write-Note 'Legacy clients seed step skipped (-SkipLegacySeed).'
}
2026-05-02 19:44:45 +12:00
2026-05-26 08:30:08 +12:00
Write-StepHeader 'Create deployment archive'
$stepWatch . Restart ( )
2026-05-02 12:39:55 +12:00
$archivePath = New-DeployArchive -ProjectPath $LocalProjectPath
2026-05-26 08:30:08 +12:00
Write-Info " Archive: $archivePath "
Write-Ok 'Archive ready' $stepWatch . Elapsed . TotalSeconds
2026-05-02 12:39:55 +12:00
2026-05-26 08:30:08 +12:00
Write-StepHeader 'Upload remote helper'
$stepWatch . Restart ( )
2026-05-11 21:02:24 +12:00
$uploadHelperPath = New-UnixScriptCopy -SourcePath $LocalRemoteHelperPath
Invoke-External -FilePath 'scp' -Arguments ( $sshArgs + @ ( $uploadHelperPath , $scpHelperTarget ) )
2026-05-26 08:30:08 +12:00
Write-Ok 'Helper uploaded' $stepWatch . Elapsed . TotalSeconds
2026-05-02 12:39:55 +12:00
2026-05-26 08:30:08 +12:00
Write-StepHeader 'Upload application archive'
$stepWatch . Restart ( )
2026-05-02 12:39:55 +12:00
Invoke-External -FilePath 'scp' -Arguments ( $sshArgs + @ ( $archivePath , $scpArchiveTarget ) )
2026-05-26 08:30:08 +12:00
Write-Ok 'Archive uploaded' $stepWatch . Elapsed . TotalSeconds
2026-05-02 12:39:55 +12:00
2026-05-26 08:30:08 +12:00
Write-StepHeader 'Run remote deployment'
$stepWatch . Restart ( )
2026-05-02 12:39:55 +12:00
Invoke-External -FilePath 'ssh' -Arguments ( $sshArgs + @ (
$sshTarget ,
'bash' ,
$RemoteHelperPath ,
'--archive' ,
$RemoteArchivePath ,
'--deploy-path' ,
$RemoteDeploymentPath ,
'--compose-file' ,
$ComposeFileName ,
'--project-name' ,
2026-05-03 11:16:53 +12:00
$DockerProjectName ,
'--nginx-source' ,
$NginxConfigSource ,
'--nginx-target' ,
$NginxConfigTarget ,
'--nginx-compose-file' ,
$NginxComposeFile ,
'--nginx-project-name' ,
2026-05-05 20:54:56 +12:00
$NginxProjectName ,
'--maintenance-host-dir' ,
$MaintenanceHostDir ,
'--maintenance-flag' ,
$MaintenanceFlagPath
2026-05-18 22:25:43 +12:00
) + $ ( if ( -not [ string ] :: IsNullOrWhiteSpace ( $Service ) ) { @ ( '--service' , $Service ) } else { @ ( ) } ) `
+ $ ( if ( $SeedAdminData ) { @ ( '--seed-admin-data' ) } else { @ ( ) } ) )
2026-05-26 08:30:08 +12:00
Write-Ok 'Remote deployment finished' $stepWatch . Elapsed . TotalSeconds
2026-05-02 12:39:55 +12:00
2026-05-26 08:30:08 +12:00
Write-StepHeader 'Clean remote temporary files'
$stepWatch . Restart ( )
2026-05-02 12:39:55 +12:00
Invoke-External -FilePath 'ssh' -Arguments ( $sshArgs + @ (
$sshTarget ,
'rm' ,
'-f' ,
$RemoteArchivePath ,
$RemoteHelperPath
) )
2026-05-26 08:30:08 +12:00
Write-Ok 'Remote temp files cleaned' $stepWatch . Elapsed . TotalSeconds
2026-05-02 12:39:55 +12:00
if ( -not $SkipSiteCheck ) {
Invoke-SiteCheck -Url $VerifyUrl
2026-05-26 08:30:08 +12:00
Invoke-AllFormSmokeTests -SshTarget $sshTarget -SshArgList $sshArgs -DeployPath $RemoteDeploymentPath
2026-05-02 12:39:55 +12:00
}
2026-05-26 08:30:08 +12:00
$totalWatch . Stop ( )
$border = '──────────────────────────────────────────────────────'
Write-Host ''
Write-Host ( Format-Color " ╭ $border ╮ " $script:CGreen )
Write-Host -NoNewline ( Format-Color ' │' $script:CGreen )
Write-Host -NoNewline ( Format-Color ' ✓ ' $script:COk )
Write-Host -NoNewline ( Format-Color 'Deployment complete' $script:CYellow )
$elapsedText = ( ' · {0:N1}s total' -f $totalWatch . Elapsed . TotalSeconds )
Write-Host -NoNewline ( Format-Color $elapsedText $script:CDim )
$visibleLen = ( ' ✓ Deployment complete' + $elapsedText ) . Length
$padLen = 54 - $visibleLen
if ( $padLen -lt 1 ) { $padLen = 1 }
Write-Host ( Format-Color ( ( ' ' * $padLen ) + '│' ) $script:CGreen )
Write-Host ( Format-Color " ╰ $border ╯ " $script:CGreen )
2026-05-02 12:39:55 +12:00
Write-Host ''
}
finally {
if ( $archivePath -and ( Test-Path -LiteralPath $archivePath ) ) {
Remove-Item -LiteralPath $archivePath -Force
}
2026-05-11 21:02:24 +12:00
if ( $uploadHelperPath -and ( Test-Path -LiteralPath $uploadHelperPath ) ) {
Remove-Item -LiteralPath $uploadHelperPath -Force
}
2026-05-02 12:39:55 +12:00
}