# Dev to prod release orchestrator. # Usage (from repo root): # npm run devtoprod:release # npm run devtoprod:release -- -DryRun # npm run devtoprod:release -- -Config infra/deploy/devtoprod.config.json # # Requires infra/deploy/devtoprod.config.json (copy from devtoprod.config.example.json). param( [string]$Config = (Join-Path $PSScriptRoot "..\deploy\devtoprod.config.json"), [switch]$DryRun ) $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "lib\Deploy-CliResult.ps1") $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path Set-Location $RepoRoot $BannerWidth = 64 $CompletedSteps = [System.Collections.Generic.List[string]]::new() $FailedStep = $null function Write-ReleaseBanner { param([bool]$Success, [string]$FailedAt = '') Write-Host '' Write-Host ('=' * $BannerWidth) if ($Success) { Write-Host ' RELEASE SUCCEEDED - production promoted' Write-Host (' Steps: ' + ($CompletedSteps -join ', ')) Write-Host ' Next: open https://gallery.mysuperlab.netcraze.pro/' } else { Write-Host " RELEASE FAILED at step: $FailedAt" if ($CompletedSteps.Count -gt 0) { Write-Host (' Completed: ' + ($CompletedSteps -join ', ')) } Write-Host " Failed: $FailedAt" } Write-Host ('=' * $BannerWidth) } function Get-StepEnabled { param([hashtable]$Steps, [string]$Name) if ($Steps.ContainsKey($Name)) { return [bool]$Steps[$Name] } return $false } function Invoke-Npm { param([string]$Script, [string[]]$ExtraArgs = @()) $npmArgs = @('run', $Script) if ($ExtraArgs.Count -gt 0) { $npmArgs += '--' $npmArgs += $ExtraArgs } Write-Host ("Running npm: npm " + ($npmArgs -join ' ')) -ForegroundColor DarkGray return Invoke-ExternalCommand -Name 'npm' -CommandArgs $npmArgs } function Invoke-Git { param([string[]]$GitArgs) Write-Host ("Running git: git " + ($GitArgs -join ' ')) -ForegroundColor DarkGray return Invoke-ExternalCommand -Name 'git' -CommandArgs $GitArgs } function Get-GitPorcelainStatus { $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' $output = & git status --porcelain 2>&1 $ErrorActionPreference = $prevEap return ($output | Out-String).Trim() } function Invoke-ExternalCommand { param( [string]$Name, [string[]]$CommandArgs ) # Capture stdout/stderr so callers assigning the return value only get exit code. $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' $output = & $Name @CommandArgs 2>&1 $exit = $LASTEXITCODE $ErrorActionPreference = $prevEap foreach ($line in $output) { Write-Host $line } return $exit } function Invoke-Step { param( [string]$Name, [scriptblock]$Action ) if ($DryRun) { Write-Host "[dry-run] Would run: $Name" $script:CompletedSteps.Add($Name) return $true } Write-Host '' Write-Host "--- Step: $Name ---" -ForegroundColor Cyan try { $code = & $Action if ($null -eq $code) { $code = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 } } if ($code -ne 0) { Write-Host ("Step failed with exit code: " + $code) -ForegroundColor Red $script:FailedStep = $Name return $false } $script:CompletedSteps.Add($Name) return $true } catch { Write-Host $_.Exception.Message -ForegroundColor Red $script:FailedStep = $Name return $false } } function Get-ProfileSteps { param([string]$Profile, [bool]$SchemaChanged) $base = @{ validateBuild = $false gitCommitPush = $false thumbnails = $false backupDev = $false backupProd = $false migrateProdSchema = $false restoreProd = $false syncImages = $false dockerPublish = $false truenasRestartPause = $false verify = $false } switch ($Profile) { 'code' { $base.validateBuild = $true $base.gitCommitPush = $true $base.dockerPublish = $true $base.truenasRestartPause = $true $base.verify = $true } 'data' { $base.validateBuild = $true $base.thumbnails = $true $base.backupDev = $true $base.backupProd = $true $base.restoreProd = $true $base.syncImages = $true $base.verify = $true } default { $base.validateBuild = $true $base.gitCommitPush = $true $base.thumbnails = $true $base.backupDev = $true $base.backupProd = $true $base.restoreProd = $true $base.syncImages = $true $base.dockerPublish = $true $base.truenasRestartPause = $true $base.verify = $true if ($SchemaChanged) { $base.migrateProdSchema = $true } } } return $base } function Merge-Steps { param([hashtable]$ProfileSteps, [object]$ConfigSteps) if ($null -eq $ConfigSteps) { return $ProfileSteps } foreach ($key in $ConfigSteps.PSObject.Properties.Name) { $ProfileSteps[$key] = [bool]$ConfigSteps.$key } return $ProfileSteps } function Get-LatestDevBackup { $backupDir = Join-Path $RepoRoot 'db\DataBackup' if (-not (Test-Path $backupDir)) { throw "Backup directory not found: $backupDir" } $latest = Get-ChildItem -Path $backupDir -Filter 'gallery_dev_data_*.txt' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $latest) { throw 'No gallery_dev_data_*.txt backup found in db/DataBackup/' } return $latest.FullName } function Assert-ProdDbTarget { $prodEnvPath = Join-Path $RepoRoot 'infra\docker\.env.prod' if (-not (Test-Path $prodEnvPath)) { throw "Missing production env file: $prodEnvPath" } $prodDbName = $null foreach ($line in Get-Content -LiteralPath $prodEnvPath) { if ($line -match '^\s*DB_NAME\s*=\s*(.+?)\s*$') { $prodDbName = $Matches[1].Trim().Trim('"', "'") break } } if (-not $prodDbName) { throw "DB_NAME is not set in $prodEnvPath" } if ($prodDbName -ne 'gallery_prod') { throw "Prod DB target is '$prodDbName' in .env.prod. Set DB_NAME=gallery_prod before dev -> prod migration." } Write-Host "Prod DB target confirmed: $prodDbName" -ForegroundColor DarkGray } function Get-DevApiPort { $envFile = Join-Path $RepoRoot '.env' $port = '3451' if (Test-Path $envFile) { foreach ($line in Get-Content -LiteralPath $envFile) { if ($line -match '^\s*PORT\s*=\s*(\d+)\s*$') { $port = $Matches[1] break } } } return $port } function Test-TcpPortListening { param([int]$Port) $matches = netstat -ano | Select-String ":$Port\s" foreach ($line in $matches) { if ($line -match 'LISTENING') { return $true } } return $false } function Start-DevNpmProcess { param([string]$ScriptName) $logPath = Join-Path $RepoRoot "infra\deploy\dev-autostart-$ScriptName.log" Write-Host "Starting background process: npm run $ScriptName (log: $logPath)" -ForegroundColor Yellow Start-Process -FilePath 'cmd.exe' -ArgumentList @( '/c', "npm run $ScriptName > `"$logPath`" 2>&1" ) -WorkingDirectory $RepoRoot -WindowStyle Hidden | Out-Null } function Ensure-DevApiReady { param( [int]$ApiPort, [bool]$AutoStart = $true, [int]$WaitSeconds = 90 ) $apiUrl = "http://127.0.0.1:$ApiPort/api/bounds" $detail = '' if (Test-BoundsJson -Url $apiUrl -MaxTimeSeconds 5 -Retries 1 -Detail ([ref]$detail)) { Write-Host "Dev API already running: $apiUrl" return $true } if (-not $AutoStart) { return $false } $viteUp = Test-TcpPortListening -Port 5173 $apiUp = Test-TcpPortListening -Port $ApiPort if ($viteUp -and -not $apiUp) { Write-Host "Vite is running on :5173 but API is down on :$ApiPort." -ForegroundColor Yellow Start-DevNpmProcess -ScriptName 'dev:server' } elseif (-not $viteUp -and -not $apiUp) { Write-Host 'Dev stack is not running.' -ForegroundColor Yellow Start-DevNpmProcess -ScriptName 'dev:web' } else { Write-Host "API port :$ApiPort is listening but /api/bounds is not ready yet. Waiting..." -ForegroundColor Yellow } for ($elapsed = 3; $elapsed -le $WaitSeconds; $elapsed += 3) { Start-Sleep -Seconds 3 if (Test-BoundsJson -Url $apiUrl -MaxTimeSeconds 5 -Retries 1 -Detail ([ref]$detail)) { Write-Host "Dev API ready: $apiUrl" return $true } Write-Host "Waiting for dev API... (${elapsed}s, $detail)" -ForegroundColor DarkGray } return $false } function Test-BoundsJson { param( [string]$Url, [switch]$Insecure, [int]$MaxTimeSeconds = 10, [int]$Retries = 3, [ref]$Detail ) $lastDetail = 'no response' for ($attempt = 1; $attempt -le $Retries; $attempt++) { $curlArgs = @('-s', '-w', "`nHTTP_CODE:%{http_code}", '--max-time', $MaxTimeSeconds) if ($Insecure) { $curlArgs += '-k' } $curlArgs += $Url $raw = (& curl.exe @curlArgs 2>&1 | Out-String).TrimEnd() $httpCode = $null $body = $raw if ($raw -match '(?s)^(.*)HTTP_CODE:(\d+)\s*$') { $body = $Matches[1].TrimEnd() $httpCode = $Matches[2] } if ($httpCode -eq '200' -and $body) { try { $json = $body | ConvertFrom-Json if ($null -ne $json.min_year -and $null -ne $json.max_year) { return $true } $lastDetail = 'HTTP 200 but JSON missing min_year/max_year' } catch { $lastDetail = 'HTTP 200 but response is not valid JSON' } } elseif ($httpCode) { $lastDetail = "HTTP $httpCode" } else { $lastDetail = "curl exit $LASTEXITCODE" } if ($attempt -lt $Retries) { Start-Sleep -Seconds 2 } } if ($Detail) { $Detail.Value = $lastDetail } return $false } # --- Load config --- if (-not (Test-Path $Config)) { Write-Host "Config not found: $Config" -ForegroundColor Red Write-Host 'Copy infra/deploy/devtoprod.config.example.json to infra/deploy/devtoprod.config.json and edit it.' Write-ReleaseBanner -Success $false -FailedAt 'loadConfig' exit 1 } $raw = Get-Content -LiteralPath $Config -Raw -Encoding UTF8 $cfg = $raw | ConvertFrom-Json $profile = if ($cfg.profile) { $cfg.profile } else { 'full' } $schemaChanged = [bool]$cfg.schemaChanged $steps = Merge-Steps (Get-ProfileSteps -Profile $profile -SchemaChanged $schemaChanged) $cfg.steps if ($cfg.autoConfirm) { $env:CONFIRM_PROD = '1' } if ($cfg.smb.user) { $env:SMB_USER = $cfg.smb.user } if ($cfg.smb.password) { $env:SMB_PASSWORD = $cfg.smb.password } $smbHost = if ($cfg.smb.host) { $cfg.smb.host } else { '192.168.10.122' } $smbShare = if ($cfg.smb.share) { $cfg.smb.share } else { 'Gallery' } $imageDest = "\\$smbHost\$smbShare\data\images" $script:backupFile = '' if ($cfg.backupFile -and $cfg.backupFile.Trim()) { $script:backupFile = (Resolve-Path (Join-Path $RepoRoot $cfg.backupFile) -ErrorAction Stop).Path } Write-Host "Gallery dev -> prod release" Write-Host "Config: $Config" Write-Host "Profile: $profile" if ($DryRun) { Write-Host 'Mode: DRY RUN (no changes)' -ForegroundColor Yellow } $enabled = @($steps.Keys | Where-Object { $steps[$_] } | Sort-Object) Write-Host ('Steps: ' + ($enabled -join ', ')) # --- Steps --- if (Get-StepEnabled $steps 'validateBuild') { $ok = Invoke-Step 'validateBuild' { $code = Invoke-Npm 'prod:build' if ($code -ne 0) { return $code } # Dev-only endpoints. Do not use verify.lanUrl here (that is prod on TrueNAS). $devApiPort = Get-DevApiPort $autoStartDev = if ($null -ne $cfg.autoStartDevStack) { [bool]$cfg.autoStartDevStack } else { $true } if (-not (Ensure-DevApiReady -ApiPort $devApiPort -AutoStart:$autoStartDev)) { Write-Host "Dev API check failed on all URLs." -ForegroundColor Red Write-Host "Start the full dev stack before release: npm run dev:web" -ForegroundColor Yellow Write-Host "Vite on :5173 alone is not enough - the API must listen on :$devApiPort (PORT in .env)." -ForegroundColor Yellow return 1 } $devCandidates = @( "http://127.0.0.1:$devApiPort/api/bounds" 'http://localhost:5173/api/bounds' 'http://192.168.10.70:5173/api/bounds' ) if ($cfg.verify.devApiUrl) { $devCandidates += $cfg.verify.devApiUrl } if ($cfg.verify.devLanUrl) { $devCandidates += $cfg.verify.devLanUrl } if ($cfg.verify.devUrl) { $devCandidates += $cfg.verify.devUrl } $devCandidates = $devCandidates | Select-Object -Unique $devOk = $false foreach ($u in $devCandidates) { Write-Host "Checking dev /api/bounds: $u" $useInsecure = $u.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase) $detail = '' if (Test-BoundsJson -Url $u -Insecure:$useInsecure -MaxTimeSeconds 10 -Retries 3 -Detail ([ref]$detail)) { Write-Host "Dev API OK: $u" $devOk = $true break } Write-Host "Dev API check failed for: $u ($detail)" -ForegroundColor DarkYellow } if (-not $devOk) { Write-Host "Dev API check failed on all URLs." -ForegroundColor Red Write-Host "Start the full dev stack before release: npm run dev:web" -ForegroundColor Yellow Write-Host "Vite on :5173 alone is not enough - the API must listen on :$devApiPort (PORT in .env)." -ForegroundColor Yellow return 1 } return 0 } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'gitCommitPush') { $ok = Invoke-Step 'gitCommitPush' { $branch = if ($cfg.git.branch) { $cfg.git.branch } else { 'main' } $message = if ($cfg.git.message) { $cfg.git.message } else { 'Release: deploy' } $stageAll = if ($null -ne $cfg.git.stageAll) { [bool]$cfg.git.stageAll } else { $true } if ($stageAll) { $code = Invoke-Git @('add', '-A') if ($code -ne 0) { return $code } } $status = Get-GitPorcelainStatus if (-not $status) { Write-Host 'Git: working tree clean, skipping commit.' } else { $code = Invoke-Git @('commit', '-m', $message) if ($code -ne 0) { return $code } } $pushAttempts = 2 for ($attempt = 1; $attempt -le $pushAttempts; $attempt++) { $code = Invoke-Git @('push', 'origin', $branch) if ($code -eq 0) { break } if ($attempt -lt $pushAttempts) { Write-Host "Git push failed (exit $code). Retrying once in 3s..." -ForegroundColor Yellow Start-Sleep -Seconds 3 } } if ($code -ne 0) { Write-Host 'Git push authentication failed for Gitea.' -ForegroundColor Red Write-Host 'Fix: create a Gitea token with repo write access, then run:' -ForegroundColor Yellow Write-Host ' git push origin main' -ForegroundColor Yellow Write-Host 'If prompted credentials fail, clear cached token:' -ForegroundColor Yellow Write-Host ' "protocol=https`nhost=gitea.mysuperlab.netcraze.pro`n" | git credential reject' -ForegroundColor Yellow Write-Host 'Then push again and resume release with gitCommitPush disabled.' -ForegroundColor Yellow return $code } return 0 } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'thumbnails') { $ok = Invoke-Step 'thumbnails' { $code = Invoke-Npm 'devtoprod:thumbnails' return $code } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'backupDev') { $ok = Invoke-Step 'backupDev' { $code = Invoke-Npm 'dev:db:backup' if ($code -ne 0) { return $code } if (-not $script:backupFile) { $script:backupFile = Get-LatestDevBackup Write-Host "Using latest dev backup: $($script:backupFile)" } return 0 } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'backupProd') { $ok = Invoke-Step 'backupProd' { Assert-ProdDbTarget $code = Invoke-Npm 'prod:db:backup' return $code } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'migrateProdSchema') { $ok = Invoke-Step 'migrateProdSchema' { Assert-ProdDbTarget $env:DB_NAME = 'gallery_prod' try { $code = Invoke-Npm 'dev:migrate' return $code } finally { Remove-Item Env:\DB_NAME -ErrorAction SilentlyContinue } } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'restoreProd') { $ok = Invoke-Step 'restoreProd' { Assert-ProdDbTarget if (-not $script:backupFile) { if ($cfg.backupFile -and $cfg.backupFile.Trim()) { $script:backupFile = (Resolve-Path (Join-Path $RepoRoot $cfg.backupFile)).Path } else { $script:backupFile = Get-LatestDevBackup } } $rel = $script:backupFile.Replace($RepoRoot, '').TrimStart('\', '/').Replace('\', '/') Write-Host "Restore file: $rel" $code = Invoke-Npm 'devtoprod:db:restore' @('--file', $rel) return $code } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'syncImages') { $ok = Invoke-Step 'syncImages' { $syncScript = Join-Path $PSScriptRoot 'sync-images-to-prod.ps1' if ($cfg.autoConfirm) { & $syncScript -Dest $imageDest -SkipConfirm } else { & $syncScript -Dest $imageDest } return $LASTEXITCODE } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'dockerPublish') { $ok = Invoke-Step 'dockerPublish' { $code = Invoke-Npm 'prod:docker:publish' return $code } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } if (Get-StepEnabled $steps 'truenasRestartPause') { if ($DryRun) { Write-Host '[dry-run] Would pause for TrueNAS gallery-web restart' $CompletedSteps.Add('truenasRestartPause') } else { Write-Host '' Write-Host '--- Manual: restart gallery-web on TrueNAS ---' -ForegroundColor Yellow Write-Host '1. TrueNAS Web UI -> Apps -> gallery-web -> Restart' Write-Host '2. Wait until status is Running' Read-Host 'Press Enter after restarting gallery-web on TrueNAS' $CompletedSteps.Add('truenasRestartPause') } } if (Get-StepEnabled $steps 'verify') { $ok = Invoke-Step 'verify' { $lanUrl = if ($cfg.verify.lanUrl) { $cfg.verify.lanUrl } else { 'http://192.168.10.122:5173/api/bounds' } $publicUrl = if ($cfg.verify.publicUrl) { $cfg.verify.publicUrl } else { 'https://gallery.mysuperlab.netcraze.pro/api/bounds' } if (-not (Test-BoundsJson -Url $lanUrl)) { Write-Host "LAN verify failed: $lanUrl" -ForegroundColor Red return 1 } Write-Host "LAN API OK: $lanUrl" if (-not (Test-BoundsJson -Url $publicUrl -Insecure)) { Write-Host "Public verify failed: $publicUrl" -ForegroundColor Red return 1 } Write-Host "Public API OK: $publicUrl" return 0 } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } } Write-ReleaseBanner -Success $true exit 0