<# .SYNOPSIS Deploys the complete Memby Docker Compose stack to MATT-NAS. .DESCRIPTION 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. 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; - unless --Quiet is supplied, tells every signed-in television a deployment is starting through the gateway that is still running before the build begins; - 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. 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 .EXAMPLE .\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180 .EXAMPLE .\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates' .EXAMPLE .\deploy-server.ps1 -ReleaseNotes 'Required security update' --m .EXAMPLE .\deploy-server.ps1 --Quiet .EXAMPLE .\deploy-server.ps1 -EstimateOnly .EXAMPLE .\deploy-server.ps1 -SkipAppRelease -SkipBuilder #> #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)] [int] $HealthTimeoutSeconds = 120, [Parameter()] [ValidatePattern('^\d+\.\d+\.\d+$')] [string] $AppVersion, [Parameter()] [string] $ReleaseNotes = '', [Parameter()] [switch] $SkipAppTests, [Parameter()] [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, [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)] [string] $TrailingFlag0, [Parameter(Position = 1)] [string] $TrailingFlag1 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $unknownFlags = @($trailingFlags | Where-Object { $_ -notin @('--m', '--Quiet', '--quiet') }) 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' if ($mandatoryRelease -and $SkipAppRelease) { throw '--m cannot be combined with -SkipAppRelease because no update would be published.' } $script:CurrentStep = 0 $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-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-Styled -Message '│ UPDATE mandatory (viewers cannot skip it)' -Colour Yellow } if ($quietDeployment) { Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray } 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, [Parameter(Mandatory)] [string] $Key ) Start-DeploymentPhase -Key $Key -Message $Message } function Write-Detail { param( [Parameter(Mandatory)] [string] $Message ) Write-Styled -Message " ↳ $Message" -Colour Gray } function Write-Success { param( [Parameter(Mandatory)] [string] $Message ) 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 { 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 } 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() } 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)] [string] $ArchivePath, [Parameter()] [string] $ReleaseDirectory ) # 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. $arguments = @( '-cf', $ArchivePath, '--exclude', 'server/.tmp-go-cache', '--exclude', 'server/.tmp-go-cache/*', '--exclude', 'server/bin', '--exclude', 'server/bin/*', '--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*', '--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*', '-C', $RepositoryDirectory, 'server', 'admin-ui', 'builder', 'docker-compose.yml', '.env.example', 'builder.env.example' ) if ($ReleaseDirectory) { $arguments += @( '-C', (Split-Path -Parent $ReleaseDirectory), (Split-Path -Leaf $ReleaseDirectory) ) } & $script:TarCommand @arguments 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 { $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 # 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." } } Import-DeploymentHistory if ($EstimateOnly) { Show-DeploymentEstimate return } Write-Banner $deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew() Write-Step 'Checking local prerequisites and settings' -Key 'prerequisites' 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) 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' -Key 'payload' $requiredPaths = @( (Join-Path $checkoutDirectory 'server'), (Join-Path $checkoutDirectory 'server/Dockerfile'), (Join-Path $checkoutDirectory 'server/go.mod'), (Join-Path $checkoutDirectory 'admin-ui'), (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 'builder.env.example') ) foreach ($requiredPath in $requiredPaths) { if (-not (Test-Path -LiteralPath $requiredPath)) { throw "Required deployment file is missing: $requiredPath" } } 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 = '' $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" } } } Write-Success 'Deployment payload is complete' Write-Host '' if (-not $SkipAppRelease) { Write-Step 'Building and verifying the signed Android update' -Key 'android-release' 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' } $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 '' } Write-Step 'Packaging the release' -Key 'packaging' New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath ` -ReleaseDirectory $releaseDirectory $archiveSize = (Get-Item -LiteralPath $archivePath).Length Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB)) Write-Success 'Release archive is ready' Write-Host '' # 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__ 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() { 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() { if [ "$colour_output" -eq 1 ]; then printf '\033[37m ↳ %s\033[0m\n' "$1"; else printf ' - %s\n' "$1"; fi } success() { if [ "$colour_output" -eq 1 ]; then printf '\033[32m ✓ %s\033[0m\n' "$1"; else printf ' OK %s\n' "$1"; fi } failure() { 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() { 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" 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 rm -rf -- "$destination" if [ -e "$backup" ] || [ -L "$backup" ]; then detail "Restoring the previous application release" mv -- "$backup" "$destination" ( cd "$destination" start_restored_stack ) || 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" start_restored_stack ) || true fi exit "$status" } 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 } trap rollback EXIT trap 'exit 130' INT TERM step 'Checking Docker' 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 } 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 success "$(docker --version)" success "$(docker compose version)" 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. 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" 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 '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 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') 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 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 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' 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' 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. # 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. if [ "$quiet_deployment" -eq 1 ]; then detail 'Quiet deployment; skipping the television announcement' elif [ -z "$previous_admin_token" ]; then 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 step 'Pulling PostgreSQL and Redis' ( cd "$staging" docker compose pull postgres redis ) success 'Dependency images are ready' 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). 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 ) 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 '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. 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 mv -- "$destination" "$backup" fi mv -- "$staging" "$destination" activated=1 success 'Release activated' step 'Starting the Compose stack' cd "$destination" 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 '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##*:} 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' 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") 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" 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 \ -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 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' '@ $remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination) $remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString()) $remoteCommand = $remoteCommand.Replace( '__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' }) ) $remoteCommand = $remoteCommand.Replace( '__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") Start-DeploymentPhase -Key 'remote-deployment' -Message "Deploying to $RemoteHost" -RemoteRange Write-Detail 'One SSH password prompt will appear' 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-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-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-Failure "Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" Write-Styled -Message " $($_.Exception.Message)" -Colour Red throw } finally { if (Test-Path -LiteralPath $workDirectory) { Remove-Item -LiteralPath $workDirectory -Recurse -Force } }