Files
memby/deploy-server.ps1
T

1675 lines
64 KiB
PowerShell
Raw Normal View History

2026-07-29 15:26:40 +12:00
<#
.SYNOPSIS
Deploys the complete Memby Docker Compose stack to MATT-NAS.
.DESCRIPTION
2026-08-02 22:10:19 +12:00
Builds and verifies a signed Memby APK, packages it with the local server build
context, Docker Compose file and .env.example, then streams everything to the NAS
over one SSH connection. Once the new gateway is healthy it publishes the APK
through the gateway's release endpoint, making it available to older TVs.
2026-07-29 15:26:40 +12:00
The remote deployment:
- checks Docker and Docker Compose;
- installs .env from the local .env.example, overwriting the deployed
copy, and keeps the old one alongside as .env.previous;
- preserves the named database volume;
2026-08-11 23:41:10 +12:00
- unless --Quiet is supplied, tells every signed-in television a deployment
is starting through the gateway that is still running before the build begins;
2026-07-29 15:26:40 +12:00
- pulls Redis and PostgreSQL, then builds the Memby server;
- starts each service and waits for its health check;
- restores the previous application files if activation fails.
Configuration lives in the local .env.example and is included directly in every
deployment. It does not need to be committed or pushed before deployment.
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.
2026-08-17 13:13:10 +12:00
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
2026-07-29 15:26:40 +12:00
2026-08-22 20:01:49 +12:00
Use -Fast for a quicker full deployment. It skips Android unit and screenshot tests and
reuses cached Docker dependency/base images when available. The signed APK build and
verification, container builds, activation, rollback, release publishing and health checks
still run.
2026-08-18 14:59:29 +12:00
Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The
console is its own container with its own health check and nothing depends on it, so
only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one
container is restarted. The gateway, PostgreSQL and Redis keep running throughout,
.env and docker-compose.yml are left exactly as deployed, no APK is built, and no
television is told anything because nothing they use goes away. It amends an existing
deployment and refuses to create one.
2026-07-29 15:26:40 +12:00
.EXAMPLE
.\deploy-server.ps1
.EXAMPLE
.\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180
2026-08-02 22:10:19 +12:00
.EXAMPLE
.\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates'
.EXAMPLE
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
2026-08-11 23:41:10 +12:00
.EXAMPLE
.\deploy-server.ps1 --Quiet
2026-08-15 09:23:26 +12:00
.EXAMPLE
.\deploy-server.ps1 -EstimateOnly
.EXAMPLE
2026-08-17 13:13:10 +12:00
.\deploy-server.ps1 -SkipAppRelease
2026-08-18 14:59:29 +12:00
2026-08-22 20:01:49 +12:00
.EXAMPLE
.\deploy-server.ps1 -Fast
2026-08-18 14:59:29 +12:00
.EXAMPLE
.\deploy-server.ps1 --Admin
.EXAMPLE
.\deploy-server.ps1 -AdminOnly
2026-07-29 15:26:40 +12:00
#>
#Requires -Version 7.2
[CmdletBinding()]
param(
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $SourceDirectory = $PSScriptRoot,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $RemoteHost = '10.0.0.213',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $RemoteUser = 'ssh',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $Destination = '/share/Docker/Memby',
[Parameter()]
[ValidateRange(30, 600)]
2026-08-02 22:10:19 +12:00
[int] $HealthTimeoutSeconds = 120,
[Parameter()]
[ValidatePattern('^\d+\.\d+\.\d+$')]
[string] $AppVersion,
[Parameter()]
[string] $ReleaseNotes = '',
[Parameter()]
[switch] $SkipAppTests,
2026-08-22 20:01:49 +12:00
[Parameter()]
[switch] $Fast,
2026-08-02 22:10:19 +12:00
[Parameter()]
2026-08-15 09:23:26 +12:00
[switch] $SkipAppRelease,
2026-08-18 14:59:29 +12:00
# Replace the operations console and nothing else. The console is its own container
# with its own health check and nothing depends on it, so it can be rebuilt and
# restarted while the gateway, PostgreSQL and Redis keep running — a console change is
# then seconds rather than the several minutes a full stack swap costs, and no
# television notices anything at all.
[Parameter()]
[Alias('Admin')]
[switch] $AdminOnly,
2026-08-02 22:10:19 +12:00
[Parameter()]
[Alias('m')]
[switch] $MandatoryUpdate,
2026-08-11 23:41:10 +12:00
[Parameter()]
[switch] $Quiet,
2026-08-15 09:23:26 +12:00
[Parameter()]
[switch] $NoAnimation,
[Parameter()]
[Alias('Preview')]
[switch] $EstimateOnly,
2026-08-11 23:41:10 +12:00
# PowerShell advanced scripts do not bind GNU-style double-dash switches by name.
# Accept the two documented flags in either positional order instead.
2026-08-02 22:10:19 +12:00
[Parameter(Position = 0)]
2026-08-11 23:41:10 +12:00
[string] $TrailingFlag0,
[Parameter(Position = 1)]
[string] $TrailingFlag1
2026-07-29 15:26:40 +12:00
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
2026-08-11 23:41:10 +12:00
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
2026-08-18 14:59:29 +12:00
$unknownFlags = @($trailingFlags | Where-Object {
2026-08-22 20:01:49 +12:00
$_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin', '--Fast', '--fast')
2026-08-18 14:59:29 +12:00
})
2026-08-11 23:41:10 +12:00
if ($unknownFlags.Count -gt 0) {
throw "Unknown deployment option: $($unknownFlags -join ', ')"
}
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
2026-08-18 14:59:29 +12:00
$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin'
2026-08-22 20:01:49 +12:00
$fastDeployment = $Fast -or $trailingFlags -contains '--Fast' -or $trailingFlags -contains '--fast'
if ($fastDeployment) {
# Screenshot tests are ordinary testDebugUnitTest classes in this repository, so the
# only reliable way to keep them out of a deployment build is to omit that Gradle task.
# assembleRelease still compiles the production app and the checks below still prove
# that the APK is signed and carries the requested version.
$SkipAppTests = $true
}
2026-08-02 22:10:19 +12:00
2026-08-18 14:59:29 +12:00
if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) {
throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.'
2026-08-02 22:10:19 +12:00
}
2026-08-18 14:59:29 +12:00
# Nothing about the gateway is rebuilt, so there is nothing for a television to be told
# about. Accepting --Quiet here would imply the announcement was a choice on this path.
if ($consoleOnly -and $quietDeployment) {
throw '--Quiet has no meaning with --Admin: no televisions are affected, so none are told.'
}
# The console-only path never packages an APK. Stated rather than silently ignored, so a
# combined invocation cannot look as though it published one.
if ($consoleOnly) {
$SkipAppRelease = $true
}
# Timings are kept per kind of deployment, because the two are not the same operation
# measured twice: a console run rebuilds one small image and a full one swaps the whole
# stack. Averaged together, each estimate would be wrong for both. A record written before
# this existed carries no kind and is read as 'full', which is what all of them were.
2026-08-22 20:01:49 +12:00
$script:DeploymentKind = if ($consoleOnly) { 'console' } elseif ($fastDeployment) { 'fast' } else { 'full' }
2026-07-29 15:26:40 +12:00
$script:CurrentStep = 0
2026-08-15 09:23:26 +12:00
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
2026-08-18 14:59:29 +12:00
# A console deployment skips the .env install, the dependency pull, the gateway build, the
# stack swap and the APK publish: what is left is check, extract, swap, build, restart, wait.
$script:RemoteStepCount = if ($consoleOnly) { 6 } else { 10 }
2026-08-15 09:23:26 +12:00
$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
2026-08-22 20:01:49 +12:00
'android-release' = if ($fastDeployment) { 150 } else { 230 }
2026-08-15 09:23:26 +12:00
'packaging' = 12
2026-08-18 14:59:29 +12:00
# A console deployment uploads one small build context and rebuilds one image, where a
# full one ships the Go tree as well and rebuilds the whole stack.
2026-08-22 20:01:49 +12:00
'remote-deployment' = if ($consoleOnly) { 90 } elseif ($fastDeployment) { 270 } else { 330 }
2026-08-15 09:23:26 +12:00
}
$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) {
2026-08-18 14:59:29 +12:00
$kindProperty = $run.PSObject.Properties['kind']
$kind = if ($kindProperty -and $kindProperty.Value) { [string]$kindProperty.Value } else { 'full' }
if ($kind -ne $script:DeploymentKind) { continue }
2026-08-15 09:23:26 +12:00
$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
2026-08-18 14:59:29 +12:00
kind = $script:DeploymentKind
2026-08-15 09:23:26 +12:00
}
$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'
2026-08-22 20:01:49 +12:00
'android-release' = if ($fastDeployment) { 'Android release build' } else { 'Android build and tests' }
2026-08-15 09:23:26 +12:00
'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
}
2026-07-29 15:26:40 +12:00
function Write-Banner {
Write-Host ''
2026-08-15 09:23:26 +12:00
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
2026-08-02 22:10:19 +12:00
if ($mandatoryRelease) {
2026-08-15 09:23:26 +12:00
Write-Styled -Message '│ UPDATE mandatory (viewers cannot skip it)' -Colour Yellow
2026-08-02 22:10:19 +12:00
}
2026-08-11 23:41:10 +12:00
if ($quietDeployment) {
2026-08-15 09:23:26 +12:00
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
2026-08-11 23:41:10 +12:00
}
2026-08-22 20:01:49 +12:00
if ($fastDeployment) {
Write-Styled -Message '│ MODE fast (tests and forced image refresh skipped)' -Colour Yellow
}
2026-08-15 09:23:26 +12:00
$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
2026-07-29 15:26:40 +12:00
Write-Host ''
}
function Write-Step {
param(
[Parameter(Mandatory)]
2026-08-15 09:23:26 +12:00
[string] $Message,
[Parameter(Mandatory)]
[string] $Key
2026-07-29 15:26:40 +12:00
)
2026-08-15 09:23:26 +12:00
Start-DeploymentPhase -Key $Key -Message $Message
2026-07-29 15:26:40 +12:00
}
function Write-Detail {
param(
[Parameter(Mandatory)]
[string] $Message
)
2026-08-15 09:23:26 +12:00
Write-Styled -Message " ↳ $Message" -Colour Gray
2026-07-29 15:26:40 +12:00
}
function Write-Success {
param(
[Parameter(Mandatory)]
[string] $Message
)
2026-08-15 09:23:26 +12:00
Write-Styled -Message "✓ $Message" -Colour Green
Update-DeploymentEta -WriteLine
}
function Write-Failure {
param([Parameter(Mandatory)][string] $Message)
Write-Styled -Message "✗ $Message" -Colour Red
2026-07-29 15:26:40 +12:00
}
function Get-RequiredCommand {
param(
[Parameter(Mandatory)]
[string] $Name
)
$command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $command) {
throw "Required command '$Name' was not found in PATH."
}
return $command.Source
}
2026-08-02 22:10:19 +12:00
function Invoke-Checked {
param(
[Parameter(Mandatory)]
[string] $FilePath,
[Parameter()]
[string[]] $Arguments = @()
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "'$FilePath' failed with exit code $LASTEXITCODE."
}
}
function Get-AndroidSdk {
param([Parameter(Mandatory)][string] $RepositoryDirectory)
if ($env:ANDROID_HOME -and (Test-Path -LiteralPath $env:ANDROID_HOME)) {
return $env:ANDROID_HOME
}
$propertiesPath = Join-Path $RepositoryDirectory 'local.properties'
if (Test-Path -LiteralPath $propertiesPath) {
$sdkLine = Get-Content -LiteralPath $propertiesPath |
Where-Object { $_ -match '^sdk\.dir=' } |
Select-Object -First 1
if ($sdkLine) {
$sdkPath = $sdkLine.Substring($sdkLine.IndexOf('=') + 1).
Replace('\:', ':').
Replace('\\', '\')
if (Test-Path -LiteralPath $sdkPath) {
return $sdkPath
}
}
}
throw 'Android SDK not found. Set ANDROID_HOME or sdk.dir in local.properties.'
}
function Import-UserSigningEnvironment {
foreach ($name in @(
'MEMBY_KEYSTORE',
'MEMBY_KEYSTORE_PASSWORD',
'MEMBY_KEY_ALIAS',
'MEMBY_KEY_PASSWORD'
)) {
$value = [Environment]::GetEnvironmentVariable($name, 'Process')
if ([string]::IsNullOrWhiteSpace($value)) {
$value = [Environment]::GetEnvironmentVariable($name, 'User')
}
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Release signing variable $name is not configured."
}
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
if (-not (Test-Path -LiteralPath $env:MEMBY_KEYSTORE -PathType Leaf)) {
throw "Release keystore not found: $env:MEMBY_KEYSTORE"
}
}
function Get-ConfiguredAppVersion {
param([Parameter(Mandatory)][string] $RepositoryDirectory)
if ($AppVersion) {
return $AppVersion
}
$gradle = Get-Content -LiteralPath (Join-Path $RepositoryDirectory 'app/build.gradle.kts') -Raw
if ($gradle -notmatch 'val defaultVersionName = "(\d+\.\d+\.\d+)"') {
throw 'Could not read defaultVersionName from app/build.gradle.kts.'
}
return $Matches[1]
}
function Get-DotEnvValue {
param(
[Parameter(Mandatory)][string] $Path,
[Parameter(Mandatory)][string] $Name
)
$line = Get-Content -LiteralPath $Path |
Where-Object { $_ -match ("^" + [regex]::Escape($Name) + "=") } |
Select-Object -First 1
if (-not $line) { return '' }
return $line.Substring($line.IndexOf('=') + 1).Trim()
}
2026-07-29 15:26:40 +12:00
function Assert-SafeRemoteSettings {
if ($RemoteHost -notmatch '^[A-Za-z0-9._:-]+$') {
throw "RemoteHost contains unsupported characters: '$RemoteHost'."
}
if ($RemoteUser -notmatch '^[A-Za-z0-9._-]+$') {
throw "RemoteUser contains unsupported characters: '$RemoteUser'."
}
if ($Destination -notmatch '^/[A-Za-z0-9._/-]+$') {
throw "Destination must be an absolute Linux path using only letters, numbers, '.', '_', '-', and '/'."
}
$segments = $Destination.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
if ($segments -contains '..') {
throw "Destination cannot contain '..' path segments."
}
$broadPaths = @(
'/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64',
'/opt', '/proc', '/root', '/run', '/sbin', '/srv', '/sys', '/tmp',
'/usr', '/var'
)
if ($Destination -in $broadPaths) {
throw "Destination '$Destination' is too broad to replace safely."
}
}
function New-DeploymentArchive {
param(
[Parameter(Mandatory)]
[string] $RepositoryDirectory,
[Parameter(Mandatory)]
2026-08-02 22:10:19 +12:00
[string] $ArchivePath,
[Parameter()]
2026-08-18 14:59:29 +12:00
[string] $ReleaseDirectory,
# Pack the console's build context alone. Everything else the remote side needs —
# the Compose file, the environment — is already deployed and is deliberately left
# alone, so it must not be shipped and cannot be changed by accident.
[Parameter()]
[switch] $ConsoleOnly
2026-07-29 15:26:40 +12:00
)
2026-08-14 09:40:03 +12:00
# Build artefacts left in either build context are paid for on the wire. Both
# directory entries and their contents are named because bsdtar — which is what
# tar.exe is on Windows — matches --exclude against each entry rather than
# pruning the walk.
2026-08-02 22:10:19 +12:00
$arguments = @(
'-cf', $ArchivePath,
2026-08-06 22:33:56 +12:00
'--exclude', 'server/.tmp-go-cache', '--exclude', 'server/.tmp-go-cache/*',
'--exclude', 'server/bin', '--exclude', 'server/bin/*',
2026-08-14 09:40:03 +12:00
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
2026-08-18 14:59:29 +12:00
'-C', $RepositoryDirectory
) + $(if ($ConsoleOnly) {
@('admin-ui')
} else {
@('server', 'admin-ui', 'docker-compose.yml', '.env.example')
})
2026-08-02 22:10:19 +12:00
if ($ReleaseDirectory) {
$arguments += @(
'-C', (Split-Path -Parent $ReleaseDirectory),
(Split-Path -Leaf $ReleaseDirectory)
)
}
& $script:TarCommand @arguments
2026-07-29 15:26:40 +12:00
if ($LASTEXITCODE -ne 0) {
throw "Unable to create the deployment archive (tar exit code $LASTEXITCODE)."
}
if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) {
throw 'The deployment archive was not created.'
}
}
function Send-ArchiveOverSsh {
param(
[Parameter(Mandatory)]
[string] $ArchivePath,
[Parameter(Mandatory)]
[string] $RemoteCommand
)
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $script:SshCommand
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardInput = $true
# Force interactive password authentication as requested. OpenSSH reads the
# password from the console while stdin carries the tar archive.
foreach ($argument in @(
'-o', 'PubkeyAuthentication=no',
'-o', 'PreferredAuthentications=keyboard-interactive,password',
'-o', 'NumberOfPasswordPrompts=3',
"$RemoteUser@$RemoteHost",
$RemoteCommand
)) {
[void] $startInfo.ArgumentList.Add($argument)
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
$archiveStream = $null
$processStarted = $false
$exitCode = $null
try {
if (-not $process.Start()) {
throw 'SSH could not be started.'
}
$processStarted = $true
$archiveStream = [System.IO.File]::OpenRead($ArchivePath)
try {
2026-08-15 09:23:26 +12:00
$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 }))
2026-07-29 15:26:40 +12:00
}
catch [System.IO.IOException] {
# The remote side exited before reading the whole archive — a failed early
# step, or a shell that rejected the command. Its own message is already on
# screen, so let the exit code below do the explaining rather than burying it
# under a broken-pipe stack trace.
}
try { $process.StandardInput.Close() } catch [System.IO.IOException] { }
$process.WaitForExit()
$exitCode = $process.ExitCode
}
catch {
if ($processStarted -and -not $process.HasExited) {
$process.StandardInput.Close()
$process.WaitForExit()
}
throw
}
finally {
if ($archiveStream) {
$archiveStream.Dispose()
}
$process.Dispose()
}
if ($exitCode -ne 0) {
throw "Remote deployment failed with SSH exit code $exitCode. Review the last remote step above."
}
}
2026-08-15 09:23:26 +12:00
Import-DeploymentHistory
if ($EstimateOnly) {
Show-DeploymentEstimate
return
}
2026-07-29 15:26:40 +12:00
Write-Banner
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()
2026-08-15 09:23:26 +12:00
Write-Step 'Checking local prerequisites and settings' -Key 'prerequisites'
2026-07-29 15:26:40 +12:00
Assert-SafeRemoteSettings
$script:TarCommand = Get-RequiredCommand -Name 'tar'
$script:SshCommand = Get-RequiredCommand -Name 'ssh'
Write-Detail "SSH $script:SshCommand"
Write-Success 'Local prerequisites are ready'
Write-Host ''
$workDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("memby-deploy-" + [guid]::NewGuid().ToString('N'))
$archivePath = Join-Path $workDirectory 'memby-deployment.tar'
try {
[void] (New-Item -ItemType Directory -Path $workDirectory)
2026-08-15 09:23:26 +12:00
Write-Step 'Selecting the local deployment source' -Key 'source'
2026-07-29 15:26:40 +12:00
$checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
Write-Success "Using $checkoutDirectory"
Write-Host ''
2026-08-18 14:59:29 +12:00
Write-Step $(if ($consoleOnly) {
'Validating the console deployment payload'
} else {
'Validating the Compose deployment payload'
}) -Key 'payload'
$consolePaths = @(
2026-08-14 09:40:03 +12:00
(Join-Path $checkoutDirectory 'admin-ui'),
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
2026-08-18 14:59:29 +12:00
(Join-Path $checkoutDirectory 'admin-ui/src')
)
# A console deployment reuses the deployed docker-compose.yml and .env rather than
# shipping its own. That is the whole safety of it: the running gateway's configuration
# is left exactly as it was, so nothing can be changed here that would need it to
# restart to take effect.
$requiredPaths = $consolePaths + $(if ($consoleOnly) { @() } else { @(
(Join-Path $checkoutDirectory 'server'),
(Join-Path $checkoutDirectory 'server/Dockerfile'),
(Join-Path $checkoutDirectory 'server/go.mod'),
2026-07-29 15:26:40 +12:00
(Join-Path $checkoutDirectory 'docker-compose.yml'),
2026-08-17 13:13:10 +12:00
(Join-Path $checkoutDirectory '.env.example')
2026-08-18 14:59:29 +12:00
) })
2026-07-29 15:26:40 +12:00
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Required deployment file is missing: $requiredPath"
}
}
2026-08-14 09:40:03 +12:00
Write-Detail 'admin-ui/ build context'
2026-08-18 14:59:29 +12:00
if (-not $consoleOnly) {
Write-Detail 'server/ build context'
Write-Detail 'docker-compose.yml'
Write-Detail '.env.example'
}
2026-08-02 22:10:19 +12:00
$releaseDirectory = ''
$releaseVersion = ''
$releaseSHA256 = ''
if (-not $SkipAppRelease) {
foreach ($appPath in @(
(Join-Path $checkoutDirectory 'app/build.gradle.kts'),
(Join-Path $checkoutDirectory 'gradlew.bat')
)) {
if (-not (Test-Path -LiteralPath $appPath -PathType Leaf)) {
throw "Required Android build file is missing: $appPath"
}
}
}
2026-07-29 15:26:40 +12:00
Write-Success 'Deployment payload is complete'
Write-Host ''
2026-08-02 22:10:19 +12:00
if (-not $SkipAppRelease) {
2026-08-15 09:23:26 +12:00
Write-Step 'Building and verifying the signed Android update' -Key 'android-release'
2026-08-02 22:10:19 +12:00
Import-UserSigningEnvironment
$releaseVersion = Get-ConfiguredAppVersion -RepositoryDirectory $checkoutDirectory
$sdk = Get-AndroidSdk -RepositoryDirectory $checkoutDirectory
$buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory |
Sort-Object { [version]$_.Name } -Descending |
Select-Object -First 1
if (-not $buildTools) {
throw 'Android SDK Build Tools are not installed.'
}
$apkSigner = Join-Path $buildTools.FullName 'apksigner.bat'
if (-not (Test-Path -LiteralPath $apkSigner -PathType Leaf)) {
throw "APK signer not found: $apkSigner"
}
$jdkHome = 'C:\Program Files\Android\Android Studio\jbr'
if (Test-Path -LiteralPath $jdkHome) {
$env:JAVA_HOME = $jdkHome
}
$gradleArguments = @('--console=plain')
if (-not $SkipAppTests) {
$gradleArguments += 'testDebugUnitTest'
2026-08-22 20:01:49 +12:00
} elseif ($fastDeployment) {
Write-Detail 'Fast mode: skipping Android unit and screenshot tests'
} else {
Write-Detail 'Android tests skipped by -SkipAppTests'
2026-08-02 22:10:19 +12:00
}
$gradleArguments += @(
'assembleRelease',
"-Pmemby.versionName=$releaseVersion"
)
Invoke-Checked -FilePath (Join-Path $checkoutDirectory 'gradlew.bat') `
-Arguments $gradleArguments
$apk = Join-Path $checkoutDirectory 'app/build/outputs/apk/release/app-release.apk'
if (-not (Test-Path -LiteralPath $apk -PathType Leaf)) {
$unsigned = Join-Path $checkoutDirectory `
'app/build/outputs/apk/release/app-release-unsigned.apk'
if (Test-Path -LiteralPath $unsigned -PathType Leaf) {
throw 'Gradle produced an unsigned APK; deployment was stopped.'
}
throw "Signed release APK not found: $apk"
}
Invoke-Checked -FilePath $apkSigner -Arguments @(
'verify', '--verbose', '--print-certs', $apk
)
$metadataPath = Join-Path $checkoutDirectory `
'app/build/outputs/apk/release/output-metadata.json'
$metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
$builtVersion = [string]$metadata.elements[0].versionName
if ($builtVersion -ne $releaseVersion) {
throw "Built APK version is $builtVersion, expected $releaseVersion."
}
$releaseDirectory = Join-Path $workDirectory 'release'
[void] (New-Item -ItemType Directory -Path $releaseDirectory)
$apkName = "memby-$releaseVersion.apk"
Copy-Item -LiteralPath $apk -Destination (Join-Path $releaseDirectory $apkName)
$releaseSHA256 = (
Get-FileHash -LiteralPath (Join-Path $releaseDirectory $apkName) -Algorithm SHA256
).Hash.ToLowerInvariant()
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText(
(Join-Path $releaseDirectory 'version.txt'), $releaseVersion, $utf8NoBom
)
[System.IO.File]::WriteAllText(
(Join-Path $releaseDirectory 'sha256.txt'), $releaseSHA256, $utf8NoBom
)
[System.IO.File]::WriteAllText(
(Join-Path $releaseDirectory 'notes.txt'), $ReleaseNotes, $utf8NoBom
)
Write-Detail "Version $releaseVersion"
Write-Detail "SHA-256 $releaseSHA256"
Write-Success 'Signed Android update is ready'
Write-Host ''
}
2026-08-18 14:59:29 +12:00
Write-Step $(if ($consoleOnly) { 'Packaging the console' } else { 'Packaging the release' }) -Key 'packaging'
2026-08-02 22:10:19 +12:00
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
2026-08-18 14:59:29 +12:00
-ReleaseDirectory $releaseDirectory -ConsoleOnly:$consoleOnly
2026-07-29 15:26:40 +12:00
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
Write-Success 'Release archive is ready'
Write-Host ''
2026-08-18 14:59:29 +12:00
# The reporting shared by both remote scripts. Kept in one place because the step
# counter, the wording and the health wait are what make a deployment readable, and two
# copies of them are two things to keep in step. Every template that uses this defines
# colour_output, remote_step, remote_step_offset, total_steps and health_timeout above
# the point it is inserted.
$remoteHelpers = @'
2026-07-29 15:26:40 +12:00
step() {
2026-08-15 09:23:26 +12:00
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
2026-07-29 15:26:40 +12:00
}
detail() {
2026-08-15 09:23:26 +12:00
if [ "$colour_output" -eq 1 ]; then printf '\033[37m ↳ %s\033[0m\n' "$1"; else printf ' - %s\n' "$1"; fi
2026-07-29 15:26:40 +12:00
}
success() {
2026-08-15 09:23:26 +12:00
if [ "$colour_output" -eq 1 ]; then printf '\033[32m ✓ %s\033[0m\n' "$1"; else printf ' OK %s\n' "$1"; fi
2026-07-29 15:26:40 +12:00
}
failure() {
2026-08-15 09:23:26 +12:00
if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi
}
2026-08-18 14:59:29 +12:00
wait_for_service() {
service="$1"
elapsed=0
detail "Waiting for $service"
while [ "$elapsed" -lt "$health_timeout" ]; do
container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)
if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
success "$service is $state ($health)"
return 0
fi
if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
failure "$service entered state: $state"
docker compose logs --no-color --tail 60 "$service" || true
return 1
fi
fi
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"
docker compose logs --no-color --tail 60 "$service" || true
return 1
}
'@
# Replacing the console alone.
#
# The console is its own container: nothing depends on it, it holds no state and it has
# a health check of its own, so it can be rebuilt and restarted while the gateway,
# PostgreSQL and Redis carry on serving. That is what makes this path safe enough to be
# worth having — and it is only safe while it stays this narrow. Three things it must
# never do: touch .env or docker-compose.yml (the running gateway's configuration would
# then differ from the file describing it, with no restart to reconcile them), name any
# service but memby-admin, or omit --no-deps (Compose would otherwise be free to
# recreate the gateway as a dependency and take the house down for a CSS change).
$consoleRemoteCommand = @'
set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
colour_output=__COLOUR_OUTPUT__
remote_step_offset=__REMOTE_STEP_OFFSET__
total_steps=__TOTAL_STEPS__
# Staged and backed up *inside* the deployment directory, so the swap is a rename on one
# filesystem rather than a copy that can be interrupted half way.
staging="${destination}/admin-ui.new.$$"
backup="${destination}/admin-ui.previous.$$"
swapped=0
remote_step=0
__SHELL_HELPERS__
restore_console() {
status=$?
trap - EXIT
if [ "$status" -eq 0 ]; then
return
fi
failure 'Console deployment failed; cleaning up'
rm -rf -- "$staging"
if [ "$swapped" -eq 1 ] && [ -d "$backup" ]; then
detail 'Restoring the previous console'
rm -rf -- "$destination/admin-ui"
mv -- "$backup" "$destination/admin-ui"
# Rebuilt as well as restored: the image that is running is the one that was just
# built from the files being thrown away, so putting the directory back without
# rebuilding would leave the NAS serving exactly what failed.
(
cd "$destination"
docker compose build memby-admin >/dev/null 2>&1 &&
docker compose up -d --no-deps --no-build memby-admin >/dev/null 2>&1
) || true
failure 'Previous console files were restored'
fi
exit "$status"
}
trap restore_console EXIT
trap 'exit 130' INT TERM
step 'Checking Docker and the deployed stack'
if ! command -v docker >/dev/null 2>&1; then
failure 'Docker is not installed on the NAS'
exit 1
fi
docker info >/dev/null 2>&1 || {
failure 'Docker is installed but the daemon is unavailable'
exit 1
}
# A console deployment amends a deployment that already exists. It cannot create one:
# there is no .env and no Compose file in this archive, by design.
if [ ! -f "$destination/docker-compose.yml" ]; then
failure "No deployment found at $destination"
detail 'Run a full deployment first; --Admin only replaces the console of an existing one'
exit 1
fi
if ! (cd "$destination" && docker compose config --services 2>/dev/null | grep -qx 'memby-admin'); then
failure 'The deployed docker-compose.yml has no memby-admin service'
detail 'That release predates the separate console; run a full deployment'
exit 1
fi
success "$(docker --version)"
success 'Existing deployment found'
step 'Extracting the console'
rm -rf -- "$staging"
mkdir -- "$staging"
tar -xf - -C "$staging"
test -f "$staging/admin-ui/Dockerfile"
test -f "$staging/admin-ui/package.json"
test -d "$staging/admin-ui/src"
success 'Console sources extracted'
step 'Replacing the console files'
rm -rf -- "$backup"
if [ -d "$destination/admin-ui" ]; then
mv -- "$destination/admin-ui" "$backup"
fi
mv -- "$staging/admin-ui" "$destination/admin-ui"
rm -rf -- "$staging"
swapped=1
success 'Console files replaced'
step 'Building the console image'
# Built before anything is restarted, so a console that does not compile leaves the one
# that is running untouched. The type check runs inside this build.
(
cd "$destination"
docker compose build memby-admin
)
success 'Console image built'
step 'Restarting the console'
(
cd "$destination"
# --no-deps is what keeps this to one container; --no-build because it was just built.
if ! docker compose up -d --no-deps --no-build memby-admin; then
failure 'Compose could not start the console'
docker compose logs --no-color --tail 60 memby-admin || true
exit 1
fi
)
success 'Console container restarted'
step 'Waiting for the console'
cd "$destination"
wait_for_service memby-admin
# The gateway is what serves /admin, and it was never restarted — so this is a check that
# the console is reachable the way an operator actually reaches it, not merely that its own
# container is up.
if command -v curl >/dev/null 2>&1; then
console_status=$(curl --silent --show-error --max-time 5 \
--output /dev/null --write-out '%{http_code}' \
http://127.0.0.1:32768/admin/ 2>/dev/null || true)
case "$console_status" in
2??|3??) success 'Console is being served through the gateway' ;;
*) detail "Console health is good but the gateway returned HTTP ${console_status:-no response} for /admin/" ;;
esac
fi
printf '\n'
docker compose ps
printf '\n'
rm -rf -- "$backup"
swapped=0
trap - EXIT INT TERM
success 'Console deployment is healthy'
success 'Memby console: https://mserver.sublogue.com/admin/'
'@
# This template is single-quoted so PowerShell does not expand the remote
# shell's variables. Replacement values are validated before insertion.
$remoteCommand = @'
set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__
2026-08-22 20:01:49 +12:00
fast_deployment=__FAST_DEPLOYMENT__
2026-08-18 14:59:29 +12:00
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
__SHELL_HELPERS__
2026-08-15 09:23:26 +12:00
start_restored_stack() {
2026-08-17 13:13:10 +12:00
docker compose up -d --build --remove-orphans >/dev/null 2>&1
2026-07-29 15:26:40 +12:00
}
rollback() {
status=$?
trap - EXIT
if [ "$status" -eq 0 ]; then
return
fi
failure "Deployment failed; cleaning up"
rm -rf -- "$staging"
if [ "$activated" -eq 1 ]; then
if [ -f "$destination/docker-compose.yml" ]; then
detail "Stopping the incomplete application release"
(
cd "$destination"
2026-08-17 13:13:10 +12:00
docker compose down --remove-orphans >/dev/null 2>&1
2026-07-29 15:26:40 +12:00
) || true
fi
rm -rf -- "$destination"
if [ -e "$backup" ] || [ -L "$backup" ]; then
detail "Restoring the previous application release"
mv -- "$backup" "$destination"
(
cd "$destination"
2026-08-15 09:23:26 +12:00
start_restored_stack
2026-07-29 15:26:40 +12:00
) || true
failure "Previous application files were restored"
fi
elif [ "$previous_stopped" -eq 1 ] && [ -f "$destination/docker-compose.yml" ]; then
detail "Restarting the previous application release"
(
cd "$destination"
2026-08-15 09:23:26 +12:00
start_restored_stack
2026-07-29 15:26:40 +12:00
) || true
fi
exit "$status"
}
trap rollback EXIT
trap 'exit 130' INT TERM
2026-08-15 09:23:26 +12:00
step 'Checking Docker'
2026-07-29 15:26:40 +12:00
if ! command -v docker >/dev/null 2>&1; then
failure 'Docker is not installed on the NAS'
exit 1
fi
docker info >/dev/null 2>&1 || {
failure 'Docker is installed but the daemon is unavailable'
exit 1
}
docker compose version >/dev/null 2>&1 || {
failure 'The Docker Compose v2 plugin is not installed'
exit 1
}
2026-08-02 22:10:19 +12:00
if [ "$publish_release" -eq 1 ] && ! command -v curl >/dev/null 2>&1; then
failure 'curl is required on the NAS to publish the signed Android update'
exit 1
fi
2026-07-29 15:26:40 +12:00
success "$(docker --version)"
success "$(docker compose version)"
2026-08-15 09:23:26 +12:00
step 'Extracting the release'
2026-07-29 15:26:40 +12:00
# 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.
mkdir -p -- "$parent" 2>/dev/null || true
if [ ! -d "$parent" ]; then
failure "$parent does not exist and could not be created by $(id -un)"
detail 'Pick a path this account can write to with -Destination'
exit 1
fi
if [ ! -w "$parent" ]; then
failure "$(id -un) cannot write to $parent"
detail 'Deploy somewhere this account owns, for example:'
detail ' .\deploy-server.ps1 -Destination /share/Docker/Memby'
detail "or grant this account write access to $parent"
exit 1
fi
rm -rf -- "$staging"
mkdir -- "$staging"
tar -xf - -C "$staging"
test -f "$staging/docker-compose.yml"
test -f "$staging/server/Dockerfile"
2026-08-14 09:40:03 +12:00
test -f "$staging/admin-ui/Dockerfile"
test -f "$staging/admin-ui/package.json"
2026-08-02 22:10:19 +12:00
if [ "$publish_release" -eq 1 ]; then
test -f "$staging/release/version.txt"
test -f "$staging/release/sha256.txt"
fi
2026-07-29 15:26:40 +12:00
success 'Release extracted'
2026-08-15 09:23:26 +12:00
step 'Installing configuration from .env.example'
2026-07-29 15:26:40 +12:00
# 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=''
2026-08-06 22:33:56 +12:00
previous_admin_token=''
2026-08-15 09:23:26 +12:00
previous_release_token=''
2026-07-29 15:26:40 +12:00
if [ -f "$destination/.env" ]; then
previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
2026-08-06 22:33:56 +12:00
# 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')
2026-08-17 13:13:10 +12:00
# First file-backed-secret deployment only: migrate the established publish token out
2026-08-15 09:23:26 +12:00
# 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')
2026-07-29 15:26:40 +12:00
# Kept beside the new one purely so a bad edit is recoverable by hand.
cp -- "$destination/.env" "$staging/.env.previous"
2026-08-15 09:23:26 +12:00
# 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
2026-07-29 15:26:40 +12:00
detail 'Previous .env saved as .env.previous'
fi
cp -- "$staging/.env.example" "$staging/.env"
success 'Installed .env from .env.example'
new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | tr -d '\r')
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')
2026-08-15 09:23:26 +12:00
secrets_dir=$(sed -n 's/^MEMBY_SECRETS_DIR=//p' "$staging/.env" | head -n 1 | tr -d '\r')
2026-07-29 15:26:40 +12:00
if [ -z "$new_password" ]; then
failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
exit 1
fi
if [ -z "$emby_url" ]; then
failure 'MEMBY_EMBY_URL is empty in .env.example'
exit 1
fi
if [ -z "$admin_token" ]; then
failure 'MEMBY_ADMIN_TOKEN is empty in .env.example; refusing to deploy with /admin disabled'
exit 1
fi
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
2026-08-15 09:23:26 +12:00
if [ -z "$secrets_dir" ] || [ "${secrets_dir#/}" = "$secrets_dir" ]; then
failure 'MEMBY_SECRETS_DIR must be an absolute NAS path outside the deployment directory'
2026-08-02 22:10:19 +12:00
exit 1
fi
2026-08-15 09:23:26 +12:00
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
2026-08-17 13:13:10 +12:00
if [ ! -s "$release_token_file" ]; then
failure "Required secret is missing or empty: $release_token_file"
detail 'Restore the existing gateway release token at the path above, then rerun this deployment'
2026-08-15 09:23:26 +12:00
exit 1
fi
# Docker Compose file-backed secrets are read-only bind mounts on the NAS. The gateway
2026-08-17 13:13:10 +12:00
# deliberately runs as uid 65532, so a file created as the SSH user with 0600 would be
# present but unreadable in that container. The 0700 parent prevents every other NAS
# account from traversing to it; 0444 makes only the read-only secret mount usable by the
# non-root container process and also prevents accidental host writes.
2026-08-15 09:23:26 +12:00
chmod 700 "$secrets_dir"
chmod 444 "$release_token_file"
2026-07-29 15:26:40 +12:00
success 'Required gateway configuration is present'
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
failure 'POSTGRES_PASSWORD differs from the deployed value'
detail 'The PostgreSQL volume is always preserved; restore the deployed password in .env.example'
detail 'Change the database credential separately with an explicit migration if required'
exit 1
fi
(
cd "$staging"
docker compose config --quiet
)
success 'Compose configuration is valid'
2026-08-15 09:23:26 +12:00
step 'Telling the televisions'
2026-08-06 22:33:56 +12:00
# 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.
# Announcing at the swap would be too late twice over — nothing is left to publish with
# once the stack is down, and Redis holds the alert list in memory with no volume, so the
# swap discards anything published but not yet collected.
#
# Entirely best-effort: a deployment must never fail over a banner, and a first
# deployment (or one onto a host with no curl) has nothing to announce with.
2026-08-11 23:41:10 +12:00
if [ "$quiet_deployment" -eq 1 ]; then
detail 'Quiet deployment; skipping the television announcement'
elif [ -z "$previous_admin_token" ]; then
2026-08-06 22:33:56 +12:00
detail 'No previous release to announce from; skipping'
elif ! command -v curl >/dev/null 2>&1; then
detail 'curl is unavailable on the NAS; skipping the announcement'
else
announce_status=$(curl --silent --show-error --max-time 5 \
--output /dev/null \
--write-out '%{http_code}' \
-X POST \
-H "Authorization: Bearer $previous_admin_token" \
http://127.0.0.1:32768/admin/api/deployment-alert 2>/dev/null || true)
case "$announce_status" in
2??) success 'Signed-in televisions have been told a deployment is starting' ;;
*) detail "Could not announce the deployment (HTTP ${announce_status:-no response})" ;;
esac
fi
2026-08-15 09:23:26 +12:00
step 'Pulling PostgreSQL and Redis'
2026-08-22 20:01:49 +12:00
if [ "$fast_deployment" -eq 1 ]; then
detail 'Fast mode; reusing cached dependency images when available'
else
(
cd "$staging"
docker compose pull postgres redis
)
success 'Dependency images are ready'
fi
2026-07-29 15:26:40 +12:00
2026-08-17 13:13:10 +12:00
step 'Building the gateway and admin console'
2026-07-29 15:26:40 +12:00
(
cd "$staging"
2026-08-14 09:40:03 +12:00
# `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).
2026-08-22 20:01:49 +12:00
if [ "$fast_deployment" -eq 1 ]; then
docker compose build server memby-admin
else
docker compose build --pull server memby-admin
fi
2026-07-29 15:26:40 +12:00
)
2026-08-17 13:13:10 +12:00
success 'Gateway and admin console images built'
2026-07-29 15:26:40 +12:00
2026-08-15 09:23:26 +12:00
step 'Activating the release'
2026-07-29 15:26:40 +12:00
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.
2026-08-17 13:13:10 +12:00
detail 'Stopping the previous Compose application'
(
cd "$destination"
docker compose down --remove-orphans
)
2026-07-29 15:26:40 +12:00
previous_stopped=1
success 'Previous Compose application stopped'
fi
mv -- "$destination" "$backup"
fi
mv -- "$staging" "$destination"
activated=1
success 'Release activated'
2026-08-15 09:23:26 +12:00
step 'Starting the Compose stack'
2026-07-29 15:26:40 +12:00
cd "$destination"
2026-08-17 13:13:10 +12:00
if ! docker compose up -d --no-build --remove-orphans; then
2026-08-15 09:23:26 +12:00
failure 'Compose could not start the complete application'
detail 'Container state before rollback:'
docker compose ps --all || true
2026-08-17 13:13:10 +12:00
detail 'Gateway logs before rollback:'
docker compose logs --no-color --tail 100 server || true
2026-08-15 09:23:26 +12:00
exit 1
fi
2026-07-29 15:26:40 +12:00
success 'Compose start command completed'
2026-08-15 09:23:26 +12:00
step 'Waiting for healthy services'
2026-07-29 15:26:40 +12:00
wait_for_service postgres
wait_for_service redis
2026-08-14 09:40:03 +12:00
wait_for_service memby-admin
2026-07-29 15:26:40 +12:00
wait_for_service server
published_address=$(docker compose port server 32768 | head -n 1)
actual_port=${published_address##*:}
if [ "$actual_port" != '32768' ]; then
failure "Memby published the wrong NAS port: ${published_address:-none}"
exit 1
fi
server_container_id=$(docker compose ps -q server)
if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$server_container_id" |
grep -q '^MEMBY_ADMIN_TOKEN=.'; then
failure 'MEMBY_ADMIN_TOKEN was not passed into the running server container'
exit 1
fi
success 'Runtime configuration includes the admin token'
2026-08-15 09:23:26 +12:00
step 'Publishing the signed Android update'
2026-08-02 22:10:19 +12:00
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")
if ! printf '%s' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
failure 'The packaged Android version is invalid'
exit 1
fi
if ! printf '%s' "$release_sha256" | grep -Eq '^[0-9a-f]{64}$'; then
failure 'The packaged Android checksum is invalid'
exit 1
fi
release_apk="$destination/release/memby-${release_version}.apk"
if [ ! -f "$release_apk" ]; then
failure "Signed APK is missing: $release_apk"
exit 1
fi
release_response="$destination/release/publish-response.json"
2026-08-15 09:23:26 +12:00
release_token=$(tr -d '\r\n' < "$release_token_file")
release_status=$(printf 'header = "Authorization: Bearer %s"\n' "$release_token" | curl --config - --show-error --silent \
2026-08-02 22:10:19 +12:00
--output "$release_response" \
--write-out '%{http_code}' \
-X POST \
-F "version=$release_version" \
-F "sha256=$release_sha256" \
-F "mandatory=$mandatory_update" \
-F "notes=<$destination/release/notes.txt" \
-F "apk=@$release_apk;type=application/vnd.android.package-archive" \
http://127.0.0.1:32768/admin/api/release)
case "$release_status" in
2??) ;;
*)
failure "Gateway rejected the Android update (HTTP ${release_status:-unknown})"
if [ -s "$release_response" ]; then
detail "$(tr -d '\r\n' < "$release_response")"
fi
exit 1
;;
esac
rm -rf -- "$destination/release"
success "Memby $release_version is available to older TVs"
else
detail 'Skipped by -SkipAppRelease'
fi
2026-07-29 15:26:40 +12:00
printf '\n'
docker compose ps
printf '\n'
rm -rf -- "$backup"
activated=0
trap - EXIT INT TERM
success 'Remote deployment is healthy'
success 'Memby gateway: https://mserver.sublogue.com'
'@
2026-08-18 14:59:29 +12:00
if ($consoleOnly) { $remoteCommand = $consoleRemoteCommand }
# The helpers go in before anything else, so a token inside them is substituted too.
$remoteCommand = $remoteCommand.Replace('__SHELL_HELPERS__', $remoteHelpers)
2026-07-29 15:26:40 +12:00
$remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
$remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
2026-08-02 22:10:19 +12:00
$remoteCommand = $remoteCommand.Replace(
'__PUBLISH_RELEASE__',
$(if ($SkipAppRelease) { '0' } else { '1' })
)
$remoteCommand = $remoteCommand.Replace(
'__MANDATORY_UPDATE__',
$(if ($mandatoryRelease) { '1' } else { '0' })
)
2026-08-11 23:41:10 +12:00
$remoteCommand = $remoteCommand.Replace(
'__QUIET_DEPLOYMENT__',
$(if ($quietDeployment) { '1' } else { '0' })
)
2026-08-22 20:01:49 +12:00
$remoteCommand = $remoteCommand.Replace(
'__FAST_DEPLOYMENT__',
$(if ($fastDeployment) { '1' } else { '0' })
)
2026-08-15 09:23:26 +12:00
$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())
2026-07-29 15:26:40 +12:00
# 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")
2026-08-18 14:59:29 +12:00
Start-DeploymentPhase -Key 'remote-deployment' -Message $(if ($consoleOnly) {
"Deploying the console to $RemoteHost"
} else {
"Deploying to $RemoteHost"
}) -RemoteRange
2026-07-29 15:26:40 +12:00
Write-Detail 'One SSH password prompt will appear'
2026-08-18 14:59:29 +12:00
if ($consoleOnly) {
Write-Detail 'The gateway, PostgreSQL and Redis keep running throughout'
}
2026-08-15 09:23:26 +12:00
Write-Detail 'Remote build output follows; its steps continue the overall counter'
2026-07-29 15:26:40 +12:00
Write-Host ''
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
2026-08-15 09:23:26 +12:00
Complete-DeploymentPhase
2026-07-29 15:26:40 +12:00
$deploymentTimer.Stop()
2026-08-15 09:23:26 +12:00
Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds
if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed }
2026-07-29 15:26:40 +12:00
Write-Host ''
2026-08-18 14:59:29 +12:00
Write-Success ($(if ($consoleOnly) {
"Console deployment complete in {0:mm\:ss}"
} else {
"Deployment complete in {0:mm\:ss}"
}) -f $deploymentTimer.Elapsed)
if ($consoleOnly) {
Write-Styled -Message ' Memby console: https://mserver.sublogue.com/admin/' -Colour White
Write-Styled -Message ' Gateway: untouched and still serving' -Colour Gray
} else {
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
}
2026-08-15 09:23:26 +12:00
Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray
Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray
2026-08-02 22:10:19 +12:00
if (-not $SkipAppRelease) {
2026-08-15 09:23:26 +12:00
Write-Styled -Message " TV update: Memby $releaseVersion (signed and published)" -Colour White
2026-08-02 22:10:19 +12:00
}
2026-07-29 15:26:40 +12:00
}
catch {
2026-08-15 09:23:26 +12:00
Complete-DeploymentPhase
2026-07-29 15:26:40 +12:00
$deploymentTimer.Stop()
2026-08-15 09:23:26 +12:00
if ($script:UseAnimation) {
Write-Progress -Id 2 -Activity 'Uploading deployment archive' -Completed
Write-Progress -Id 1 -Activity 'Memby deployment' -Completed
}
2026-07-29 15:26:40 +12:00
Write-Host ''
2026-08-15 09:23:26 +12:00
Write-Failure "Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))"
Write-Styled -Message " $($_.Exception.Message)" -Colour Red
2026-07-29 15:26:40 +12:00
throw
}
finally {
if (Test-Path -LiteralPath $workDirectory) {
Remove-Item -LiteralPath $workDirectory -Recurse -Force
}
}