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