Add one-command dev-to-prod release with clear SUCCESS/FAILED banners.

Introduce devtoprod:release orchestrator, config file, CLI result footers on deploy scripts, auto-thumb regeneration on curator fixes, and updated deploy documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-08 14:54:56 +03:00
co-authored by Cursor
parent 313666a4ab
commit 21e3e41e48
20 changed files with 839 additions and 86 deletions
@@ -0,0 +1,36 @@
{
"_comment": "Copy to devtoprod.config.json (gitignored) and edit before running npm run devtoprod:release",
"profile": "full",
"autoConfirm": true,
"steps": {
"validateBuild": true,
"gitCommitPush": true,
"thumbnails": true,
"backupDev": true,
"backupProd": true,
"migrateProdSchema": false,
"restoreProd": true,
"syncImages": true,
"dockerPublish": true,
"truenasRestartPause": true,
"verify": true
},
"git": {
"branch": "main",
"message": "Release: weekly deploy",
"stageAll": true
},
"schemaChanged": false,
"backupFile": "",
"smb": {
"host": "192.168.10.122",
"share": "Gallery",
"user": "",
"password": ""
},
"verify": {
"devUrl": "https://devgallery.mysuperlab.netcraze.pro/api/bounds",
"lanUrl": "http://192.168.10.122:5173/api/bounds",
"publicUrl": "https://gallery.mysuperlab.netcraze.pro/api/bounds"
}
}
+14 -3
View File
@@ -6,6 +6,8 @@ param(
)
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "..\scripts\lib\Deploy-CliResult.ps1")
$Registry = "gitea.mysuperlab.netcraze.pro"
$Image = "$Registry/danilka/gallery-web"
@@ -17,7 +19,7 @@ function Test-DockerRunning {
Write-Host "=== Build + LAN push to Gitea ===" -ForegroundColor Cyan
if (-not (Test-DockerRunning)) {
Write-Host "Docker is not running. Start Docker Desktop and retry." -ForegroundColor Red
Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary 'Docker is not running. Start Docker Desktop and retry.'
exit 1
}
@@ -25,7 +27,10 @@ if (-not $SkipBuild) {
Write-Host ""
Write-Host "Building ${Image}:${Tag} ..."
docker build -f infra/docker/Dockerfile -t "${Image}:${Tag}" .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($LASTEXITCODE -ne 0) {
Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary "Docker build failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
Write-Host "Build complete." -ForegroundColor Green
} else {
Write-Host "Skipping build (-SkipBuild)." -ForegroundColor Yellow
@@ -33,4 +38,10 @@ if (-not $SkipBuild) {
Write-Host ""
& "$PSScriptRoot/push-lan.ps1" -Tag $Tag -SkipHosts:$SkipHosts
exit $LASTEXITCODE
if ($LASTEXITCODE -ne 0) {
Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary "Docker push failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
Write-DeployCliResult -Script 'build-push-lan' -Success $true -Summary 'Docker image built and pushed to Gitea.' -Details @("Image: ${Image}:${Tag}")
exit 0
+388
View File
@@ -0,0 +1,388 @@
# 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 = @())
$args = @('run', $Script)
if ($ExtraArgs.Count -gt 0) {
$args += '--'
$args += $ExtraArgs
}
& npm @args
return $LASTEXITCODE
}
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) {
$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 Test-BoundsJson {
param([string]$Url, [switch]$Insecure)
$curlArgs = @('-s', '-f')
if ($Insecure) { $curlArgs += '-k' }
$curlArgs += $Url
$body = & curl.exe @curlArgs 2>$null
if ($LASTEXITCODE -ne 0 -or -not $body) {
return $false
}
try {
$json = $body | ConvertFrom-Json
return ($null -ne $json.min_year -and $null -ne $json.max_year)
} catch {
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 }
$devUrl = if ($cfg.verify.devUrl) { $cfg.verify.devUrl } else { 'https://devgallery.mysuperlab.netcraze.pro/api/bounds' }
if (-not (Test-BoundsJson -Url $devUrl -Insecure)) {
Write-Host "Dev API check failed: $devUrl" -ForegroundColor Red
return 1
}
Write-Host "Dev API OK: $devUrl"
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) {
git add -A
if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE }
}
$status = git status --porcelain
if (-not $status) {
Write-Host 'Git: working tree clean, skipping commit.'
} else {
git commit -m $message
if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE }
}
git push origin $branch
if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE }
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' {
$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' {
$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' {
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
+29
View File
@@ -0,0 +1,29 @@
# Standardized final SUCCESS/FAILED banner for deploy PowerShell scripts.
$script:DeployCliResultWidth = 72
function Write-DeployCliResult {
param(
[Parameter(Mandatory = $true)]
[string]$Script,
[Parameter(Mandatory = $true)]
[bool]$Success,
[string]$Summary = '',
[string[]]$Details = @()
)
$label = if ($Success) { 'SUCCESS' } else { 'FAILED' }
$banner = "===== ${label}: $Script ====="
$pad = [Math]::Max(0, $script:DeployCliResultWidth - $banner.Length)
$line = $banner + ('=' * $pad)
Write-Host ''
Write-Host $line
if ($Summary) { Write-Host $Summary }
foreach ($detail in $Details) {
if ($detail) { Write-Host $detail }
}
Write-Host ('=' * $script:DeployCliResultWidth)
}
+8 -5
View File
@@ -12,6 +12,8 @@ param(
)
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "lib\Deploy-CliResult.ps1")
$Source = (Resolve-Path $Source -ErrorAction Stop).Path
Write-Host "Source: $Source"
@@ -20,8 +22,8 @@ Write-Host "Dest: $Dest"
if (-not $SkipConfirm) {
$confirm = Read-Host "Copy all files (skip older)? Type yes"
if ($confirm -ne "yes") {
Write-Host "Aborted."
exit 0
Write-DeployCliResult -Script 'sync-images-to-prod' -Success $false -Summary 'Image sync aborted by user.'
exit 1
}
}
@@ -34,7 +36,7 @@ if ($env:SMB_USER -and $env:SMB_PASSWORD) {
Write-Warning "Map the share first, e.g.:"
Write-Warning (' net use ' + $smbRoot + ' /user:YOUR_TRUENAS_USER')
Write-Warning 'or set $env:SMB_USER and $env:SMB_PASSWORD before running this script.'
Write-Error 'SMB share not reachable - aborting before robocopy.'
Write-DeployCliResult -Script 'sync-images-to-prod' -Success $false -Summary 'SMB share not reachable - aborting before robocopy.'
exit 1
}
@@ -49,8 +51,9 @@ try {
robocopy $Source $Dest /E /XO /R:2 /W:3 /NFL /NDL /NJH /NJS
$code = $LASTEXITCODE
if ($code -ge 8) {
Write-Error "robocopy failed with exit code $code"
Write-DeployCliResult -Script 'sync-images-to-prod' -Success $false -Summary "robocopy failed with exit code $code"
exit $code
}
Write-Host "Image sync complete (robocopy exit $code)."
Write-DeployCliResult -Script 'sync-images-to-prod' -Success $true -Summary 'Image sync complete.' -Details @("robocopy exit code: $code", "Dest: $Dest")
exit 0