Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+321 -15
View File
@@ -3,8 +3,10 @@
Deploys the complete Memby Docker Compose stack to MATT-NAS.
.DESCRIPTION
Packages the local server build context, Docker Compose file and .env.example,
then streams them to the NAS over one SSH connection.
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;
@@ -28,6 +30,12 @@ This deploys the current local working tree, including uncommitted server change
.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
#>
#Requires -Version 7.2
@@ -52,20 +60,52 @@ param(
[Parameter()]
[ValidateRange(30, 600)]
[int] $HealthTimeoutSeconds = 120
[int] $HealthTimeoutSeconds = 120,
[Parameter()]
[ValidatePattern('^\d+\.\d+\.\d+$')]
[string] $AppVersion,
[Parameter()]
[string] $ReleaseNotes = '',
[Parameter()]
[switch] $SkipAppTests,
[Parameter()]
[switch] $SkipAppRelease
,
[Parameter()]
[Alias('m')]
[switch] $MandatoryUpdate,
# PowerShell advanced scripts do not bind GNU-style double-dash switches. Accept
# --m explicitly as the sole positional argument so it can still sit at the end.
[Parameter(Position = 0)]
[ValidateSet('--m')]
[string] $MandatoryFlag
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$mandatoryRelease = $MandatoryUpdate -or $MandatoryFlag -eq '--m'
if ($mandatoryRelease -and $SkipAppRelease) {
throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
}
$script:CurrentStep = 0
$script:TotalSteps = 5
$script:TotalSteps = if ($SkipAppRelease) { 5 } else { 6 }
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
if ($mandatoryRelease) {
Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow
}
Write-Host '╰─' -ForegroundColor Magenta
Write-Host ''
}
@@ -114,6 +154,91 @@ function Get-RequiredCommand {
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'."
@@ -148,10 +273,24 @@ function New-DeploymentArchive {
[string] $RepositoryDirectory,
[Parameter(Mandatory)]
[string] $ArchivePath
[string] $ArchivePath,
[Parameter()]
[string] $ReleaseDirectory
)
& $script:TarCommand -cf $ArchivePath -C $RepositoryDirectory server docker-compose.yml .env.example
$arguments = @(
'-cf', $ArchivePath,
'-C', $RepositoryDirectory,
'server', 'docker-compose.yml', '.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)."
}
@@ -270,11 +409,106 @@ try {
Write-Detail 'server/ build context'
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"
}
}
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'
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'
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath
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'
@@ -287,6 +521,8 @@ set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
@@ -386,7 +622,7 @@ wait_for_service() {
trap rollback EXIT
trap 'exit 130' INT TERM
step '[remote 1/8] Checking Docker'
step '[remote 1/9] Checking Docker'
if ! command -v docker >/dev/null 2>&1; then
failure 'Docker is not installed on the NAS'
exit 1
@@ -399,10 +635,14 @@ 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 '[remote 2/8] Extracting the release'
step '[remote 2/9] 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.
@@ -424,9 +664,13 @@ mkdir -- "$staging"
tar -xf - -C "$staging"
test -f "$staging/docker-compose.yml"
test -f "$staging/server/Dockerfile"
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/8] Installing configuration from .env.example'
step '[remote 3/9] 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.
@@ -445,6 +689,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')
if [ -z "$new_password" ]; then
failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
exit 1
@@ -461,6 +706,10 @@ 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'
exit 1
fi
success 'Required gateway configuration is present'
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
failure 'POSTGRES_PASSWORD differs from the deployed value'
@@ -475,21 +724,21 @@ fi
)
success 'Compose configuration is valid'
step '[remote 4/8] Pulling PostgreSQL and Redis'
step '[remote 4/9] Pulling PostgreSQL and Redis'
(
cd "$staging"
docker compose pull postgres redis
)
success 'Dependency images are ready'
step '[remote 5/8] Building memby-server'
step '[remote 5/9] Building memby-server'
(
cd "$staging"
docker compose build --pull server
)
success 'Server image built'
step '[remote 6/8] Activating the release'
step '[remote 6/9] Activating the release'
rm -rf -- "$backup"
if [ -e "$destination" ] || [ -L "$destination" ]; then
if [ -f "$destination/docker-compose.yml" ]; then
@@ -511,12 +760,12 @@ mv -- "$staging" "$destination"
activated=1
success 'Release activated'
step '[remote 7/8] Starting the Compose stack'
step '[remote 7/9] Starting the Compose stack'
cd "$destination"
docker compose up -d --remove-orphans
success 'Compose start command completed'
step '[remote 8/8] Waiting for healthy services'
step '[remote 8/9] Waiting for healthy services'
wait_for_service postgres
wait_for_service redis
wait_for_service server
@@ -536,6 +785,52 @@ if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$serve
fi
success 'Runtime configuration includes the admin token'
step '[remote 9/9] 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_status=$(curl --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" \
-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'
@@ -549,6 +844,14 @@ 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(
'__MANDATORY_UPDATE__',
$(if ($mandatoryRelease) { '1' } else { '0' })
)
# 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
@@ -567,6 +870,9 @@ success 'Memby gateway: https://mserver.sublogue.com'
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
if (-not $SkipAppRelease) {
Write-Host " TV update: Memby $releaseVersion (signed and published)" -ForegroundColor White
}
}
catch {
$deploymentTimer.Stop()