2026-05-02 12:39:55 +12:00
|
|
|
[CmdletBinding()]
|
|
|
|
|
param(
|
|
|
|
|
[switch]$Force,
|
2026-05-02 19:44:45 +12:00
|
|
|
[switch]$SkipSiteCheck,
|
|
|
|
|
[string]$Service
|
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'
|
|
|
|
|
|
|
|
|
|
# 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"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Push-Location $ProjectPath
|
|
|
|
|
try {
|
|
|
|
|
Invoke-External -FilePath 'node' -Arguments @(
|
|
|
|
|
'--experimental-strip-types',
|
|
|
|
|
$scriptPath,
|
|
|
|
|
$OutputPath
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
|
Pop-Location
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 12:39:55 +12:00
|
|
|
function Invoke-SiteCheck {
|
|
|
|
|
param([string]$Url)
|
|
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host "[deploy] Checking production site: $Url"
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$response = Invoke-WebRequest -Uri $Url -MaximumRedirection 5 -TimeoutSec 30
|
|
|
|
|
Write-Host "[deploy] Site responded with HTTP $($response.StatusCode)"
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
Write-Warning "Production site check failed: $($_.Exception.Message)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-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
|
|
|
|
|
|
|
|
|
|
Write-Host '[deploy] Main Goodwalk website deployment'
|
|
|
|
|
Write-Host "[deploy] Local project path: $LocalProjectPath"
|
|
|
|
|
Write-Host "[deploy] Remote deployment path: $RemoteDeploymentPath"
|
|
|
|
|
Write-Host "[deploy] Remote compose file: $ComposeFileName"
|
|
|
|
|
Write-Host "[deploy] Docker project name: $DockerProjectName"
|
|
|
|
|
Write-Host "[deploy] SSH target: $sshTarget"
|
|
|
|
|
Write-Host "[deploy] SSH config: $SshConfigPath"
|
2026-05-02 19:44:45 +12:00
|
|
|
if (-not [string]::IsNullOrWhiteSpace($Service)) {
|
|
|
|
|
Write-Host "[deploy] Target service: $Service"
|
|
|
|
|
}
|
2026-05-02 12:39:55 +12:00
|
|
|
if ([string]::IsNullOrWhiteSpace($SshKeyPath)) {
|
|
|
|
|
Write-Host '[deploy] SSH auth: interactive password prompt'
|
|
|
|
|
} else {
|
|
|
|
|
Write-Host "[deploy] SSH auth: key file $SshKeyPath"
|
|
|
|
|
}
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Safety notes:'
|
|
|
|
|
Write-Host ' - Only the top-level Goodwalk compose project will be updated.'
|
|
|
|
|
Write-Host ' - Legacy WordPress/onboarding compose files are not used.'
|
|
|
|
|
Write-Host ' - Remote .env files are preserved because they are not uploaded.'
|
|
|
|
|
Write-Host ' - No global Docker prune/stop/delete commands are used.'
|
|
|
|
|
|
|
|
|
|
if (-not $Force) {
|
|
|
|
|
$confirmation = Read-Host "Type DEPLOY to continue with the remote path '$RemoteDeploymentPath'"
|
|
|
|
|
if ($confirmation -ne 'DEPLOY') {
|
|
|
|
|
throw 'Deployment cancelled.'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$archivePath = $null
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-02 19:44:45 +12:00
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Exporting current homepage content for PostgreSQL sync'
|
|
|
|
|
Export-HomepageContent -ProjectPath $LocalProjectPath -OutputPath $GeneratedHomepageContentPath
|
|
|
|
|
|
2026-05-02 12:39:55 +12:00
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Creating deployment archive'
|
|
|
|
|
$archivePath = New-DeployArchive -ProjectPath $LocalProjectPath
|
|
|
|
|
Write-Host "[deploy] Archive ready: $archivePath"
|
|
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Uploading remote helper'
|
|
|
|
|
Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($LocalRemoteHelperPath, $scpHelperTarget))
|
|
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Uploading application archive'
|
|
|
|
|
Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($archivePath, $scpArchiveTarget))
|
|
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Running remote deployment'
|
|
|
|
|
Invoke-External -FilePath 'ssh' -Arguments ($sshArgs + @(
|
|
|
|
|
$sshTarget,
|
|
|
|
|
'bash',
|
|
|
|
|
$RemoteHelperPath,
|
|
|
|
|
'--archive',
|
|
|
|
|
$RemoteArchivePath,
|
|
|
|
|
'--deploy-path',
|
|
|
|
|
$RemoteDeploymentPath,
|
|
|
|
|
'--compose-file',
|
|
|
|
|
$ComposeFileName,
|
|
|
|
|
'--project-name',
|
|
|
|
|
$DockerProjectName
|
2026-05-02 19:44:45 +12:00
|
|
|
) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }))
|
2026-05-02 12:39:55 +12:00
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Cleaning remote temporary files'
|
|
|
|
|
Invoke-External -FilePath 'ssh' -Arguments ($sshArgs + @(
|
|
|
|
|
$sshTarget,
|
|
|
|
|
'rm',
|
|
|
|
|
'-f',
|
|
|
|
|
$RemoteArchivePath,
|
|
|
|
|
$RemoteHelperPath
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
if (-not $SkipSiteCheck) {
|
|
|
|
|
Invoke-SiteCheck -Url $VerifyUrl
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Write-Host ''
|
|
|
|
|
Write-Host '[deploy] Deployment completed successfully'
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
|
if ($archivePath -and (Test-Path -LiteralPath $archivePath)) {
|
|
|
|
|
Remove-Item -LiteralPath $archivePath -Force
|
|
|
|
|
}
|
|
|
|
|
}
|