0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+582 -67
View File
@@ -26,6 +26,8 @@ SSH performs the password prompt directly. The password is never read or stored
by this script.
This deploys the current local working tree, including uncommitted server changes.
Use -SkipAppRelease -SkipBuilder for an admin/server-only deployment: no APK is built or
published, and the running Android builder container and image are left untouched.
.EXAMPLE
.\deploy-server.ps1
@@ -41,6 +43,12 @@ This deploys the current local working tree, including uncommitted server change
.EXAMPLE
.\deploy-server.ps1 --Quiet
.EXAMPLE
.\deploy-server.ps1 -EstimateOnly
.EXAMPLE
.\deploy-server.ps1 -SkipAppRelease -SkipBuilder
#>
#Requires -Version 7.2
@@ -78,9 +86,13 @@ param(
[switch] $SkipAppTests,
[Parameter()]
[switch] $SkipAppRelease
[switch] $SkipAppRelease,
# Preserve the Android builder container and reuse its image. This skips the SDK
# image build and leaves the release controller untouched during an admin/server deploy.
[Parameter()]
[switch] $SkipBuilder,
,
[Parameter()]
[Alias('m')]
[switch] $MandatoryUpdate,
@@ -88,6 +100,13 @@ param(
[Parameter()]
[switch] $Quiet,
[Parameter()]
[switch] $NoAnimation,
[Parameter()]
[Alias('Preview')]
[switch] $EstimateOnly,
# PowerShell advanced scripts do not bind GNU-style double-dash switches by name.
# Accept the two documented flags in either positional order instead.
[Parameter(Position = 0)]
@@ -112,31 +131,327 @@ if ($mandatoryRelease -and $SkipAppRelease) {
}
$script:CurrentStep = 0
$script:TotalSteps = if ($SkipAppRelease) { 5 } else { 6 }
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
$script:RemoteStepCount = 10
$script:TotalSteps = $script:LocalStepCount + $script:RemoteStepCount
$script:PhaseOrder = @(
'prerequisites',
'source',
'payload'
) + $(if ($SkipAppRelease) { @() } else { @('android-release') }) + @(
'packaging',
'remote-deployment'
)
$script:FallbackSeconds = @{
'prerequisites' = 3
'source' = 2
'payload' = 4
'android-release' = 230
'packaging' = 12
'remote-deployment' = 330
}
$script:PhaseDurations = [ordered]@{}
$script:CurrentPhaseKey = $null
$script:CurrentPhaseTimer = $null
$script:History = @()
$script:IsCI = -not [string]::IsNullOrWhiteSpace($env:CI) -and $env:CI -notin @('0', 'false', 'False')
$script:UseColour = [string]::IsNullOrWhiteSpace($env:NO_COLOR) -and
-not [Console]::IsOutputRedirected -and
-not $script:IsCI
$script:UseAnimation = -not $NoAnimation -and
[Environment]::UserInteractive -and
-not [Console]::IsOutputRedirected -and
-not $script:IsCI
function Write-Styled {
param(
[Parameter(Mandatory)][string] $Message,
[Parameter(Mandatory)][ConsoleColor] $Colour,
[switch] $NoNewline
)
$arguments = @{ Object = $Message; NoNewline = $NoNewline }
if ($script:UseColour) { $arguments.ForegroundColor = $Colour }
Write-Host @arguments
}
function Format-DeploymentDuration {
param([Parameter(Mandatory)][double] $Seconds)
$seconds = [Math]::Max(0, [Math]::Round($Seconds))
if ($seconds -lt 60) { return "${seconds}s" }
$span = [TimeSpan]::FromSeconds($seconds)
if ($span.TotalHours -ge 1) {
return ('{0}h {1}m' -f [Math]::Floor($span.TotalHours), $span.Minutes)
}
return ('{0}m {1}s' -f $span.Minutes, $span.Seconds)
}
function Get-DeploymentHistoryPath {
$root = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
Join-Path ([System.IO.Path]::GetTempPath()) 'Memby'
} else {
Join-Path $env:LOCALAPPDATA 'Memby'
}
return Join-Path $root 'deploy-history/deploy-server.json'
}
function Import-DeploymentHistory {
$script:HistoryPath = Get-DeploymentHistoryPath
if (-not (Test-Path -LiteralPath $script:HistoryPath -PathType Leaf)) { return }
try {
$document = Get-Content -LiteralPath $script:HistoryPath -Raw | ConvertFrom-Json
$script:History = @($document.runs | Where-Object { $_.success -eq $true })
}
catch {
# Estimates are advisory. A truncated or hand-edited history file must never stop
# a deployment, and the next successful run will replace it atomically.
$script:History = @()
}
}
function Get-PhaseHistory {
param([Parameter(Mandatory)][string] $Key)
$values = foreach ($run in $script:History) {
$phasesProperty = $run.PSObject.Properties['phases']
if (-not $phasesProperty -or -not $phasesProperty.Value) { continue }
$property = $phasesProperty.Value.PSObject.Properties[$Key]
if ($property -and [double]$property.Value -gt 0) { [double]$property.Value }
}
return @($values | Select-Object -Last 20)
}
function Get-PhaseEstimate {
param([Parameter(Mandatory)][string] $Key)
$historyValues = @(Get-PhaseHistory -Key $Key)
if ($historyValues.Count -eq 0) {
return [pscustomobject]@{
Seconds = [double]$script:FallbackSeconds[$Key]
Low = $null; High = $null; Samples = 0; Last = $null
}
}
$last = $historyValues[-1]
$values = @($historyValues | Sort-Object)
$middle = [Math]::Floor($values.Count / 2)
$median = if ($values.Count % 2) { $values[$middle] } else { ($values[$middle - 1] + $values[$middle]) / 2 }
return [pscustomobject]@{
Seconds = [double]$median
Low = [double]$values[[Math]::Floor(($values.Count - 1) * 0.25)]
High = [double]$values[[Math]::Ceiling(($values.Count - 1) * 0.75)]
Samples = $values.Count
Last = [double]$last
}
}
function Get-RemainingEstimate {
param([Parameter(Mandatory)][string] $CurrentKey)
$remaining = 0.0
$found = $false
foreach ($key in $script:PhaseOrder) {
if ($key -eq $CurrentKey) { $found = $true }
if (-not $found) { continue }
$estimate = Get-PhaseEstimate -Key $key
if ($key -eq $CurrentKey -and $script:CurrentPhaseTimer) {
$remaining += [Math]::Max(0, $estimate.Seconds - $script:CurrentPhaseTimer.Elapsed.TotalSeconds)
} else {
$remaining += $estimate.Seconds
}
}
return $remaining
}
function Show-DeploymentProgress {
param(
[Parameter(Mandatory)][string] $Key,
[Parameter(Mandatory)][string] $Status,
[int] $CompletedPhases = 0
)
if (-not $script:UseAnimation) { return }
$remaining = Get-RemainingEstimate -CurrentKey $Key
$percent = [Math]::Min(99, [Math]::Round(($CompletedPhases / $script:PhaseOrder.Count) * 100))
Write-Progress -Id 1 -Activity 'Memby deployment' -Status $Status `
-PercentComplete $percent -SecondsRemaining ([Math]::Max(0, [int]$remaining))
}
function Update-DeploymentEta {
param([switch] $WriteLine)
if (-not $script:CurrentPhaseKey -or -not $script:CurrentPhaseTimer) { return }
$phaseIndex = [Array]::IndexOf($script:PhaseOrder, $script:CurrentPhaseKey)
$phaseEstimate = Get-PhaseEstimate -Key $script:CurrentPhaseKey
$phaseElapsed = $script:CurrentPhaseTimer.Elapsed.TotalSeconds
$phaseRemaining = [Math]::Max(0, $phaseEstimate.Seconds - $phaseElapsed)
$remaining = Get-RemainingEstimate -CurrentKey $script:CurrentPhaseKey
if ($script:UseAnimation) {
$withinPhase = if ($phaseEstimate.Seconds -gt 0) {
[Math]::Min(0.95, $phaseElapsed / $phaseEstimate.Seconds)
} else { 0 }
$percent = [Math]::Min(99, [Math]::Round(
(($phaseIndex + $withinPhase) / $script:PhaseOrder.Count) * 100
))
Write-Progress -Id 1 -Activity 'Memby deployment' `
-Status ("{0} elapsed in this phase · {1} overall remaining" -f `
(Format-DeploymentDuration $phaseElapsed),
(Format-DeploymentDuration $remaining)) `
-PercentComplete $percent -SecondsRemaining ([int]$remaining)
}
if ($WriteLine) {
$phaseText = if ($phaseRemaining -gt 0) {
"about $(Format-DeploymentDuration $phaseRemaining) left in this phase"
} else {
'running beyond its typical phase time'
}
Write-Styled -Message (" ◷ {0} elapsed · {1} · about {2} overall, near {3}" -f `
(Format-DeploymentDuration $phaseElapsed),
$phaseText,
(Format-DeploymentDuration $remaining),
(Get-Date).AddSeconds($remaining).ToString('h:mm tt')) -Colour Yellow
}
}
function Complete-DeploymentPhase {
if (-not $script:CurrentPhaseKey -or -not $script:CurrentPhaseTimer) { return }
$script:CurrentPhaseTimer.Stop()
$script:PhaseDurations[$script:CurrentPhaseKey] = [Math]::Round(
$script:CurrentPhaseTimer.Elapsed.TotalSeconds, 2
)
$script:CurrentPhaseKey = $null
$script:CurrentPhaseTimer = $null
}
function Start-DeploymentPhase {
param(
[Parameter(Mandatory)][string] $Key,
[Parameter(Mandatory)][string] $Message,
[switch] $RemoteRange
)
Complete-DeploymentPhase
$script:CurrentPhaseKey = $Key
$script:CurrentPhaseTimer = [System.Diagnostics.Stopwatch]::StartNew()
$phaseIndex = [Array]::IndexOf($script:PhaseOrder, $Key)
Show-DeploymentProgress -Key $Key -Status $Message -CompletedPhases ([Math]::Max(0, $phaseIndex))
Write-Styled -Message '● ' -Colour Magenta -NoNewline
if ($RemoteRange) {
Write-Styled -Message ("[{0}{1}/{1}] " -f ($script:LocalStepCount + 1), $script:TotalSteps) -Colour DarkCyan -NoNewline
} else {
$script:CurrentStep++
Write-Styled -Message ("[{0}/{1}] " -f $script:CurrentStep, $script:TotalSteps) -Colour DarkCyan -NoNewline
}
Write-Styled -Message $Message -Colour Cyan
$phase = Get-PhaseEstimate -Key $Key
$remaining = Get-RemainingEstimate -CurrentKey $Key
$readyAt = (Get-Date).AddSeconds($remaining).ToString('h:mm tt')
$basis = if ($phase.Samples -eq 0) {
'first-run estimate; timings will improve after this deployment'
} elseif ($phase.Samples -eq 1) {
'based on the previous successful deployment'
} else {
"median of $($phase.Samples) successful deployments"
}
Write-Styled -Message (" ◷ About {0} remaining · ready near {1} · {2}" -f `
(Format-DeploymentDuration $remaining), $readyAt, $basis) -Colour Yellow
if ($phase.Samples -gt 1) {
Write-Styled -Message (" Typical phase range {0}{1}; last run {2}" -f `
(Format-DeploymentDuration $phase.Low),
(Format-DeploymentDuration $phase.High),
(Format-DeploymentDuration $phase.Last)) -Colour Gray
}
}
function Save-DeploymentHistory {
param([Parameter(Mandatory)][bool] $Success, [Parameter(Mandatory)][double] $DurationSeconds)
if (-not $Success) { return }
$record = [ordered]@{
completedAt = (Get-Date).ToUniversalTime().ToString('o')
success = $true
durationSeconds = [Math]::Round($DurationSeconds, 2)
phases = $script:PhaseDurations
appRelease = -not [bool]$SkipAppRelease
}
$runs = @($script:History) + @([pscustomobject]$record) | Select-Object -Last 30
$directory = Split-Path -Parent $script:HistoryPath
[void](New-Item -ItemType Directory -Path $directory -Force)
$temporary = "$($script:HistoryPath).tmp.$PID"
try {
@{ schemaVersion = 1; runs = @($runs) } | ConvertTo-Json -Depth 8 |
Set-Content -LiteralPath $temporary -Encoding utf8
[System.IO.File]::Move($temporary, $script:HistoryPath, $true)
}
catch {
Write-Styled -Message ' ↳ Deployment succeeded, but timing history could not be saved' -Colour Yellow
}
finally {
if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force }
}
}
function Show-DeploymentEstimate {
Write-Banner
Write-Styled -Message 'Estimated phase timings' -Colour Cyan
$labels = @{
'prerequisites' = 'Local prerequisites'
'source' = 'Source selection'
'payload' = 'Compose validation'
'android-release' = 'Android build and tests'
'packaging' = 'Release packaging'
'remote-deployment' = 'Upload, remote build and activation'
}
foreach ($key in $script:PhaseOrder) {
$estimate = Get-PhaseEstimate -Key $key
$detail = if ($estimate.Samples -gt 1) {
"typical {0}{1}; {2} samples" -f `
(Format-DeploymentDuration $estimate.Low),
(Format-DeploymentDuration $estimate.High),
$estimate.Samples
} elseif ($estimate.Samples -eq 1) {
'one successful deployment recorded'
} else {
'baseline until a successful deployment is recorded'
}
Write-Styled -Message (" {0,-38} {1,9} {2}" -f `
$labels[$key], (Format-DeploymentDuration $estimate.Seconds), $detail) -Colour Gray
}
Write-Host ''
Write-Styled -Message "Timing history: $($script:HistoryPath)" -Colour DarkCyan
}
function Write-Banner {
Write-Host ''
Write-Host '╭─ Memby deployment' -ForegroundColor Magenta
Write-Host "│ Source $SourceDirectory (local working tree)" -ForegroundColor DarkGray
Write-Host "│ Target ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray
Write-Styled -Message '╭──────────────────── Memby deployment ────────────────────╮' -Colour Magenta
Write-Styled -Message '│ SOURCE ' -Colour DarkCyan -NoNewline
Write-Styled -Message "$SourceDirectory (local working tree)" -Colour Gray
Write-Styled -Message '│ TARGET ' -Colour DarkCyan -NoNewline
Write-Styled -Message "${RemoteUser}@${RemoteHost}:$Destination" -Colour White
if ($mandatoryRelease) {
Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow
Write-Styled -Message '│ UPDATE mandatory (viewers cannot skip it)' -Colour Yellow
}
if ($quietDeployment) {
Write-Host '│ Notice quiet (no advance television announcement)' -ForegroundColor DarkGray
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
}
Write-Host '╰─' -ForegroundColor Magenta
if ($SkipBuilder) {
Write-Styled -Message '│ BUILDER preserve the running Android builder' -Colour Gray
}
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
Measure-Object -Sum).Sum
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
$historyRuns = if ($sampleCounts.Count) { ($sampleCounts | Measure-Object -Minimum).Minimum } else { 0 }
$estimateBasis = if ($historyRuns -gt 0) { "median history ($historyRuns+ runs)" } else { 'learning baseline' }
Write-Styled -Message '│ ETA ' -Colour DarkCyan -NoNewline
Write-Styled -Message ("about {0}, near {1} · {2}" -f `
(Format-DeploymentDuration $initialEstimate),
(Get-Date).AddSeconds($initialEstimate).ToString('h:mm tt'),
$estimateBasis) -Colour Yellow
Write-Styled -Message '╰───────────────────────────────────────────────────────────╯' -Colour Magenta
Write-Host ''
}
function Write-Step {
param(
[Parameter(Mandatory)]
[string] $Message
[string] $Message,
[Parameter(Mandatory)]
[string] $Key
)
$script:CurrentStep++
Write-Host ("● [{0}/{1}] {2}" -f $script:CurrentStep, $script:TotalSteps, $Message) -ForegroundColor Cyan
Start-DeploymentPhase -Key $Key -Message $Message
}
function Write-Detail {
@@ -145,7 +460,7 @@ function Write-Detail {
[string] $Message
)
Write-Host "$Message" -ForegroundColor DarkGray
Write-Styled -Message "$Message" -Colour Gray
}
function Write-Success {
@@ -154,7 +469,13 @@ function Write-Success {
[string] $Message
)
Write-Host "$Message" -ForegroundColor Green
Write-Styled -Message "$Message" -Colour Green
Update-DeploymentEta -WriteLine
}
function Write-Failure {
param([Parameter(Mandatory)][string] $Message)
Write-Styled -Message "$Message" -Colour Red
}
function Get-RequiredCommand {
@@ -309,7 +630,8 @@ function New-DeploymentArchive {
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
'-C', $RepositoryDirectory,
'server', 'admin-ui', 'docker-compose.yml', '.env.example'
'server', 'admin-ui', 'builder', 'docker-compose.yml', '.env.example',
'builder.env.example'
)
if ($ReleaseDirectory) {
$arguments += @(
@@ -367,7 +689,40 @@ function Send-ArchiveOverSsh {
$archiveStream = [System.IO.File]::OpenRead($ArchivePath)
try {
$archiveStream.CopyTo($process.StandardInput.BaseStream)
$totalBytes = $archiveStream.Length
$transferred = 0L
$buffer = [byte[]]::new(1MB)
$transferTimer = [System.Diagnostics.Stopwatch]::StartNew()
$lastRefresh = [TimeSpan]::Zero
$nextLoggedPercent = 25
while (($read = $archiveStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
$process.StandardInput.BaseStream.Write($buffer, 0, $read)
$transferred += $read
if (($transferTimer.Elapsed - $lastRefresh).TotalMilliseconds -lt 250) { continue }
$lastRefresh = $transferTimer.Elapsed
$percent = if ($totalBytes -gt 0) { [Math]::Min(100, [int](100 * $transferred / $totalBytes)) } else { 0 }
$bytesPerSecond = if ($transferTimer.Elapsed.TotalSeconds -gt 0) {
$transferred / $transferTimer.Elapsed.TotalSeconds
} else { 0 }
$secondsLeft = if ($bytesPerSecond -gt 0) {
[int](($totalBytes - $transferred) / $bytesPerSecond)
} else { -1 }
if ($script:UseAnimation) {
$rate = if ($bytesPerSecond -gt 0) { '{0:N1} MiB/s' -f ($bytesPerSecond / 1MB) } else { 'measuring speed' }
Write-Progress -Id 2 -ParentId 1 -Activity 'Uploading deployment archive' `
-Status ("{0}% · {1}" -f $percent, $rate) -PercentComplete $percent `
-SecondsRemaining $secondsLeft
} elseif ($percent -ge $nextLoggedPercent) {
Write-Detail ("Upload {0}% · {1:N1} MiB of {2:N1} MiB" -f `
$percent, ($transferred / 1MB), ($totalBytes / 1MB))
$nextLoggedPercent += 25
}
}
$process.StandardInput.BaseStream.Flush()
if ($script:UseAnimation) { Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed }
Write-Success ("Archive uploaded in {0} at {1:N1} MiB/s" -f `
(Format-DeploymentDuration $transferTimer.Elapsed.TotalSeconds),
$(if ($transferTimer.Elapsed.TotalSeconds -gt 0) { ($transferred / 1MB) / $transferTimer.Elapsed.TotalSeconds } else { 0 }))
}
catch [System.IO.IOException] {
# The remote side exited before reading the whole archive — a failed early
@@ -398,10 +753,15 @@ function Send-ArchiveOverSsh {
}
}
Import-DeploymentHistory
if ($EstimateOnly) {
Show-DeploymentEstimate
return
}
Write-Banner
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
Write-Step 'Checking local prerequisites and settings'
Write-Step 'Checking local prerequisites and settings' -Key 'prerequisites'
Assert-SafeRemoteSettings
$script:TarCommand = Get-RequiredCommand -Name 'tar'
$script:SshCommand = Get-RequiredCommand -Name 'ssh'
@@ -415,12 +775,12 @@ $archivePath = Join-Path $workDirectory 'memby-deployment.tar'
try {
[void] (New-Item -ItemType Directory -Path $workDirectory)
Write-Step 'Selecting the local deployment source'
Write-Step 'Selecting the local deployment source' -Key 'source'
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
Write-Success "Using $checkoutDirectory"
Write-Host ''
Write-Step 'Validating the Compose deployment payload'
Write-Step 'Validating the Compose deployment payload' -Key 'payload'
$requiredPaths = @(
(Join-Path $checkoutDirectory 'server'),
(Join-Path $checkoutDirectory 'server/Dockerfile'),
@@ -429,8 +789,13 @@ try {
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
(Join-Path $checkoutDirectory 'admin-ui/src'),
(Join-Path $checkoutDirectory 'builder'),
(Join-Path $checkoutDirectory 'builder/Dockerfile'),
(Join-Path $checkoutDirectory 'builder/release.sh'),
(Join-Path $checkoutDirectory 'builder/controller.go'),
(Join-Path $checkoutDirectory 'docker-compose.yml'),
(Join-Path $checkoutDirectory '.env.example')
(Join-Path $checkoutDirectory '.env.example'),
(Join-Path $checkoutDirectory 'builder.env.example')
)
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
@@ -439,6 +804,7 @@ try {
}
Write-Detail 'server/ build context'
Write-Detail 'admin-ui/ build context'
Write-Detail 'builder/ release toolchain'
Write-Detail 'docker-compose.yml'
Write-Detail '.env.example'
$releaseDirectory = ''
@@ -453,18 +819,12 @@ try {
throw "Required Android build file is missing: $appPath"
}
}
if ([string]::IsNullOrWhiteSpace(
(Get-DotEnvValue -Path (Join-Path $checkoutDirectory '.env.example') `
-Name 'MEMBY_RELEASE_PUBLISH_TOKEN')
)) {
throw 'MEMBY_RELEASE_PUBLISH_TOKEN is empty in .env.example; a signed APK cannot be published.'
}
}
Write-Success 'Deployment payload is complete'
Write-Host ''
if (-not $SkipAppRelease) {
Write-Step 'Building and verifying the signed Android update'
Write-Step 'Building and verifying the signed Android update' -Key 'android-release'
Import-UserSigningEnvironment
$releaseVersion = Get-ConfiguredAppVersion -RepositoryDirectory $checkoutDirectory
$sdk = Get-AndroidSdk -RepositoryDirectory $checkoutDirectory
@@ -538,7 +898,7 @@ try {
Write-Host ''
}
Write-Step 'Packaging the release'
Write-Step 'Packaging the release' -Key 'packaging'
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
-ReleaseDirectory $releaseDirectory
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
@@ -554,28 +914,50 @@ set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
skip_builder=__SKIP_BUILDER__
mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__
colour_output=__COLOUR_OUTPUT__
remote_step_offset=__REMOTE_STEP_OFFSET__
total_steps=__TOTAL_STEPS__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
activated=0
previous_stopped=0
remote_step=0
step() {
printf '\033[36m ● %s\033[0m\n' "$1"
remote_step=$((remote_step + 1))
overall_step=$((remote_step_offset + remote_step))
if [ "$colour_output" -eq 1 ]; then
printf '\033[35m ● \033[36m[%s/%s] %s\033[0m\n' "$overall_step" "$total_steps" "$1"
else
printf ' * [%s/%s] %s\n' "$overall_step" "$total_steps" "$1"
fi
}
detail() {
printf '\033[90m ↳ %s\033[0m\n' "$1"
if [ "$colour_output" -eq 1 ]; then printf '\033[37m ↳ %s\033[0m\n' "$1"; else printf ' - %s\n' "$1"; fi
}
success() {
printf '\033[32m ✓ %s\033[0m\n' "$1"
if [ "$colour_output" -eq 1 ]; then printf '\033[32m ✓ %s\033[0m\n' "$1"; else printf ' OK %s\n' "$1"; fi
}
failure() {
printf '\033[31m ✗ %s\033[0m\n' "$1" >&2
if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi
}
start_restored_stack() {
if [ "$skip_builder" -eq 1 ]; then
# Restore the previous gateway/admin sources without rebuilding the large
# Android SDK image or touching the still-running builder, database and cache.
docker compose build server memby-admin >/dev/null 2>&1 &&
docker compose up -d --no-build --no-deps memby-admin server >/dev/null 2>&1
else
docker compose up -d --build --remove-orphans >/dev/null 2>&1
fi
}
rollback() {
@@ -594,7 +976,12 @@ rollback() {
detail "Stopping the incomplete application release"
(
cd "$destination"
docker compose down --remove-orphans >/dev/null 2>&1
if [ "$skip_builder" -eq 1 ]; then
docker compose stop server memby-admin >/dev/null 2>&1
docker compose rm -f server memby-admin >/dev/null 2>&1
else
docker compose down --remove-orphans >/dev/null 2>&1
fi
) || true
fi
@@ -604,7 +991,7 @@ rollback() {
mv -- "$backup" "$destination"
(
cd "$destination"
docker compose up -d --build --remove-orphans >/dev/null 2>&1
start_restored_stack
) || true
failure "Previous application files were restored"
fi
@@ -612,7 +999,7 @@ rollback() {
detail "Restarting the previous application release"
(
cd "$destination"
docker compose up -d --build --remove-orphans >/dev/null 2>&1
start_restored_stack
) || true
fi
@@ -645,6 +1032,10 @@ wait_for_service() {
sleep 2
elapsed=$((elapsed + 2))
if [ $((elapsed % 10)) -eq 0 ]; then
remaining=$((health_timeout - elapsed))
detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining"
fi
done
failure "$service did not become healthy within ${health_timeout}s"
@@ -655,7 +1046,7 @@ wait_for_service() {
trap rollback EXIT
trap 'exit 130' INT TERM
step '[remote 1/10] Checking Docker'
step 'Checking Docker'
if ! command -v docker >/dev/null 2>&1; then
failure 'Docker is not installed on the NAS'
exit 1
@@ -675,7 +1066,7 @@ fi
success "$(docker --version)"
success "$(docker compose version)"
step '[remote 2/10] Extracting the release'
step 'Extracting the release'
# Checked before anything is created: the staging directory, the swap and the backup all
# need write access to the parent, and "can't create directory" from BusyBox halfway
# through a deployment is a poor way to learn the account cannot write there.
@@ -699,26 +1090,37 @@ test -f "$staging/docker-compose.yml"
test -f "$staging/server/Dockerfile"
test -f "$staging/admin-ui/Dockerfile"
test -f "$staging/admin-ui/package.json"
test -f "$staging/builder/Dockerfile"
test -f "$staging/builder/release.sh"
test -f "$staging/builder/controller.go"
if [ "$publish_release" -eq 1 ]; then
test -f "$staging/release/version.txt"
test -f "$staging/release/sha256.txt"
fi
success 'Release extracted'
step '[remote 3/10] Installing configuration from .env.example'
step 'Installing configuration from .env.example'
# The local .env.example is the single source of truth for configuration and carries
# real values rather than placeholders. Every deployment overwrites the deployed .env
# with it, so neither a Git push nor an SSH edit is needed.
previous_password=''
previous_admin_token=''
previous_release_token=''
if [ -f "$destination/.env" ]; then
previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
# The token of the gateway that is still running, which is the one that can still
# tell the televisions anything. It is usually the same as the incoming one, but
# reading it from the release being replaced is what makes that not a requirement.
previous_admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
# First Docker-builder deployment only: migrate the established publish token out
# of the old environment file rather than making the operator rotate it mid-release.
previous_release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
# Kept beside the new one purely so a bad edit is recoverable by hand.
cp -- "$destination/.env" "$staging/.env.previous"
# The migrated release credential belongs only in the external secret file. Do not
# perpetuate it in the recovery copy once it has been captured in memory.
sed -i '/^MEMBY_RELEASE_PUBLISH_TOKEN=/d' "$staging/.env.previous"
chmod 600 "$staging/.env.previous" 2>/dev/null || true
detail 'Previous .env saved as .env.previous'
fi
@@ -729,7 +1131,7 @@ new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | t
admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r')
configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r')
release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
secrets_dir=$(sed -n 's/^MEMBY_SECRETS_DIR=//p' "$staging/.env" | head -n 1 | tr -d '\r')
if [ -z "$new_password" ]; then
failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
exit 1
@@ -746,10 +1148,64 @@ if [ "$configured_port" != '32768' ]; then
failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})"
exit 1
fi
if [ "$publish_release" -eq 1 ] && [ -z "$release_token" ]; then
failure 'MEMBY_RELEASE_PUBLISH_TOKEN is empty; the signed APK cannot be published'
if [ -z "$secrets_dir" ] || [ "${secrets_dir#/}" = "$secrets_dir" ]; then
failure 'MEMBY_SECRETS_DIR must be an absolute NAS path outside the deployment directory'
exit 1
fi
release_token_file="$secrets_dir/memby-release-publish-token"
if [ ! -s "$release_token_file" ]; then
if [ -n "$previous_release_token" ]; then
mkdir -p -- "$secrets_dir"
chmod 700 "$secrets_dir" 2>/dev/null || true
old_umask=$(umask)
umask 077
printf '%s\n' "$previous_release_token" > "$release_token_file"
umask "$old_umask"
success "Migrated the existing release publish token to $release_token_file"
else
failure "Release publish secret is missing or empty: $release_token_file"
detail 'Create it with the existing release token; do not generate a replacement if CI still uses that token'
fi
fi
missing_builder_secrets=0
check_required_secret() {
required_secret="$1"
if [ ! -s "$required_secret" ]; then
failure "Required secret is missing or empty: $required_secret"
missing_builder_secrets=1
fi
}
check_required_secret "$release_token_file"
if [ "$skip_builder" -ne 1 ]; then
check_required_secret "$secrets_dir/memby-release.jks"
check_required_secret "$secrets_dir/memby-keystore-password"
check_required_secret "$secrets_dir/memby-key-alias"
check_required_secret "$secrets_dir/memby-key-password"
fi
if [ "$missing_builder_secrets" -ne 0 ]; then
if [ "$skip_builder" -eq 1 ]; then
detail 'Restore the existing gateway release token at the path above, then rerun this deployment'
else
detail 'Copy the existing signing identity and its three values to the paths above, then rerun this deployment'
detail 'Never create a new keystore: installed Memby clients can upgrade only from the existing certificate'
fi
exit 1
fi
# Docker Compose file-backed secrets are read-only bind mounts on the NAS. The gateway
# and builder deliberately run as uid 65532, so files created as the SSH user with 0600
# would be present but unreadable in those containers. The 0700 parent prevents every
# other NAS account from traversing to them; 0444 makes only the read-only secret mounts
# usable by the non-root container processes and also prevents accidental host writes.
chmod 700 "$secrets_dir"
chmod 444 "$release_token_file"
if [ "$skip_builder" -ne 1 ]; then
chmod 444 \
"$secrets_dir/memby-release.jks" \
"$secrets_dir/memby-keystore-password" \
"$secrets_dir/memby-key-alias" \
"$secrets_dir/memby-key-password"
fi
success 'Required gateway configuration is present'
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
failure 'POSTGRES_PASSWORD differs from the deployed value'
@@ -764,7 +1220,7 @@ fi
)
success 'Compose configuration is valid'
step '[remote 4/10] Telling the televisions'
step 'Telling the televisions'
# Announced here rather than at the swap, and the position is the point of it: the
# gateway being replaced is still answering, and the build below takes minutes, which is
# the window in which every open television polls /v1/status and collects the notice.
@@ -793,35 +1249,63 @@ else
esac
fi
step '[remote 5/10] Pulling PostgreSQL and Redis'
step 'Pulling PostgreSQL and Redis'
(
cd "$staging"
docker compose pull postgres redis
)
success 'Dependency images are ready'
step '[remote 6/10] Building the gateway and admin console'
if [ "$skip_builder" -eq 1 ]; then
step 'Building the gateway and admin console; reusing the Android builder'
else
step 'Building the gateway, admin console and Android builder'
fi
(
cd "$staging"
# `up` reuses an existing image when one is present. Build both local contexts here,
# otherwise a new React/nginx console can be packaged and activated while the NAS
# continues to serve the previous console image (and its old route configuration).
docker compose build --pull server memby-admin
if [ "$skip_builder" -eq 1 ]; then
existing_builder=$(docker compose ps -q memby-builder 2>/dev/null || true)
if [ -z "$existing_builder" ] ||
[ "$(docker inspect --format '{{.State.Status}}' "$existing_builder" 2>/dev/null || true)" != 'running' ]; then
failure 'No running memby-builder container is available to preserve'
detail 'Run once without -SkipBuilder to install and start the Android builder'
exit 1
fi
docker compose build --pull server memby-admin
else
docker compose build --pull server memby-admin memby-builder
fi
)
success 'Gateway and admin console images built'
if [ "$skip_builder" -eq 1 ]; then
success 'Gateway and admin console images built; Android builder container retained'
else
success 'Gateway, admin console and Android builder images built'
fi
step '[remote 7/10] Activating the release'
step 'Activating the release'
rm -rf -- "$backup"
if [ -e "$destination" ] || [ -L "$destination" ]; then
if [ -f "$destination/docker-compose.yml" ]; then
# Compose projects created by older releases may use a different project
# name. Stop them from their original directory before moving it so their
# published ports (especially 32768) are released for the new stack.
detail 'Stopping the previous Compose application'
(
cd "$destination"
docker compose down --remove-orphans
)
if [ "$skip_builder" -eq 1 ]; then
detail 'Stopping only the gateway and admin console; preserving builder, database and cache'
(
cd "$destination"
docker compose stop server memby-admin
docker compose rm -f server memby-admin
)
else
detail 'Stopping the previous Compose application'
(
cd "$destination"
docker compose down --remove-orphans
)
fi
previous_stopped=1
success 'Previous Compose application stopped'
fi
@@ -832,16 +1316,29 @@ mv -- "$staging" "$destination"
activated=1
success 'Release activated'
step '[remote 8/10] Starting the Compose stack'
step 'Starting the Compose stack'
cd "$destination"
docker compose up -d --remove-orphans
if [ "$skip_builder" -eq 1 ]; then
compose_start='docker compose up -d --no-build --no-deps memby-admin server'
else
compose_start='docker compose up -d --no-build --remove-orphans'
fi
if ! $compose_start; then
failure 'Compose could not start the complete application'
detail 'Container state before rollback:'
docker compose ps --all || true
detail 'Gateway and builder logs before rollback:'
docker compose logs --no-color --tail 100 server memby-builder || true
exit 1
fi
success 'Compose start command completed'
step '[remote 9/10] Waiting for healthy services'
step 'Waiting for healthy services'
wait_for_service postgres
wait_for_service redis
wait_for_service memby-admin
wait_for_service server
wait_for_service memby-builder
published_address=$(docker compose port server 32768 | head -n 1)
actual_port=${published_address##*:}
@@ -858,7 +1355,7 @@ if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$serve
fi
success 'Runtime configuration includes the admin token'
step '[remote 10/10] Publishing the signed Android update'
step 'Publishing the signed Android update'
if [ "$publish_release" -eq 1 ]; then
release_version=$(tr -d '\r\n' < "$destination/release/version.txt")
release_sha256=$(tr -d '\r\n' < "$destination/release/sha256.txt")
@@ -877,11 +1374,11 @@ if [ "$publish_release" -eq 1 ]; then
fi
release_response="$destination/release/publish-response.json"
release_status=$(curl --show-error --silent \
release_token=$(tr -d '\r\n' < "$release_token_file")
release_status=$(printf 'header = "Authorization: Bearer %s"\n' "$release_token" | curl --config - --show-error --silent \
--output "$release_response" \
--write-out '%{http_code}' \
-X POST \
-H "Authorization: Bearer $release_token" \
-F "version=$release_version" \
-F "sha256=$release_sha256" \
-F "mandatory=$mandatory_update" \
@@ -921,6 +1418,10 @@ success 'Memby gateway: https://mserver.sublogue.com'
'__PUBLISH_RELEASE__',
$(if ($SkipAppRelease) { '0' } else { '1' })
)
$remoteCommand = $remoteCommand.Replace(
'__SKIP_BUILDER__',
$(if ($SkipBuilder) { '1' } else { '0' })
)
$remoteCommand = $remoteCommand.Replace(
'__MANDATORY_UPDATE__',
$(if ($mandatoryRelease) { '1' } else { '0' })
@@ -929,33 +1430,47 @@ success 'Memby gateway: https://mserver.sublogue.com'
'__QUIET_DEPLOYMENT__',
$(if ($quietDeployment) { '1' } else { '0' })
)
$remoteCommand = $remoteCommand.Replace(
'__COLOUR_OUTPUT__',
$(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' })
)
$remoteCommand = $remoteCommand.Replace('__REMOTE_STEP_OFFSET__', $script:LocalStepCount.ToString())
$remoteCommand = $remoteCommand.Replace('__TOTAL_STEPS__', $script:TotalSteps.ToString())
# This file is edited on Windows, so the here-string above arrives with whatever line
# endings it was saved with. A remote shell reads a trailing carriage return as part
# of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
# script is normalised to LF here rather than depending on how it was saved.
$remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")
Write-Step "Deploying to $RemoteHost"
Start-DeploymentPhase -Key 'remote-deployment' -Message "Deploying to $RemoteHost" -RemoteRange
Write-Detail 'One SSH password prompt will appear'
Write-Detail 'Remote build output follows'
Write-Detail 'Remote build output follows; its steps continue the overall counter'
Write-Host ''
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
Complete-DeploymentPhase
$deploymentTimer.Stop()
Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds
if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed }
Write-Host ''
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
Write-Host ' Memby gateway: https://mserver.sublogue.com' -ForegroundColor White
Write-Host " NAS endpoint: http://${RemoteHost}:32768" -ForegroundColor DarkGray
Write-Host " Install path: ${RemoteHost}:$Destination" -ForegroundColor DarkGray
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray
Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray
if (-not $SkipAppRelease) {
Write-Host " TV update: Memby $releaseVersion (signed and published)" -ForegroundColor White
Write-Styled -Message " TV update: Memby $releaseVersion (signed and published)" -Colour White
}
}
catch {
Complete-DeploymentPhase
$deploymentTimer.Stop()
if ($script:UseAnimation) {
Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed
Write-Progress -Id 1 -Activity 'Memby deployment' -Completed
}
Write-Host ''
Write-Host "Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
Write-Failure "Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))"
Write-Styled -Message " $($_.Exception.Message)" -Colour Red
throw
}
finally {