Files
moonwell-launcher/tool/deploy_launcher.ps1
2026-07-28 21:28:53 +04:00

714 lines
22 KiB
PowerShell

<#
.SYNOPSIS
Builds, signs, verifies, and deploys MoonWell Launcher to production.
.DESCRIPTION
Builds the Windows application and Inno Setup installer, verifies the DSA
signing key pair, signs the installer, generates appcast.xml, uploads the
installer to Yandex Object Storage, and publishes AppCast through the MoonWell
service API. Secrets are read only from environment variables.
.PARAMETER ExpectedVersion
Fails unless the X.Y.Z part of pubspec.yaml matches this value.
.PARAMETER ReleaseNotes
Text written to the AppCast item description.
.PARAMETER EnvFile
Local dotenv file used to fill environment variables that are not already set.
.PARAMETER DryRun
Builds, signs, and verifies local artifacts without changing production.
.PARAMETER Force
Allows redeploying a version that is not newer than the public AppCast.
.PARAMETER SkipChecks
Skips pub get, formatting, static analysis, and tests.
.EXAMPLE
.\tool\deploy_launcher.ps1 -DryRun -ExpectedVersion 1.0.2 `
-ReleaseNotes "MoonWell Launcher improvements."
.EXAMPLE
.\tool\deploy_launcher.ps1 -ExpectedVersion 1.0.2 `
-ReleaseNotes "MoonWell Launcher improvements."
#>
#requires -Version 5.1
[CmdletBinding()]
param(
[string]$ExpectedVersion,
[string]$ReleaseNotes = 'MoonWell Launcher update.',
[string]$ApiBaseUrl = 'https://moon-well.online',
[string]$AppcastUrl = 'https://moon-well.online/appcast.xml',
[string]$S3Endpoint,
[string]$S3Bucket,
[string]$S3Key = 'moonwell_launcher_setup.exe',
[string]$EnvFile = '.env',
[string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem',
[string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem',
[string]$FlutterCommand = 'flutter',
[string]$DartCommand = 'dart',
[string]$InnoSetupCommand,
[string]$OpenSslCommand,
[switch]$SkipChecks,
[switch]$DryRun,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:SparkleNamespace =
'http://www.andymatuschak.org/xml-namespaces/sparkle'
function Write-Step {
param([Parameter(Mandatory = $true)][string]$Message)
Write-Host "`n==> $Message" -ForegroundColor Cyan
}
function Resolve-RequiredCommand {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string]$Description
)
if (Test-Path -LiteralPath $Command -PathType Leaf) {
return (Resolve-Path -LiteralPath $Command).Path
}
$resolved = Get-Command $Command -ErrorAction SilentlyContinue
if ($null -eq $resolved) {
throw "$Description was not found: $Command"
}
return $resolved.Source
}
function Invoke-RequiredCommand {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code ${LASTEXITCODE}: $Command"
}
}
function Get-FileSha256 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Import-DotEnv {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return
}
$allowedNames = @(
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_DEFAULT_REGION',
'AWS_BUCKET',
'AWS_ENDPOINT',
'AWS_USE_PATH_STYLE_ENDPOINT',
'LAUNCHER_AUTH_KEY'
)
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if (
[string]::IsNullOrWhiteSpace($trimmed) -or
$trimmed.StartsWith('#')
) {
continue
}
$separator = $trimmed.IndexOf('=')
if ($separator -le 0) {
throw "Invalid dotenv line in ${Path}: $line"
}
$name = $trimmed.Substring(0, $separator).Trim()
if ($allowedNames -notcontains $name) {
continue
}
$value = $trimmed.Substring($separator + 1).Trim()
if (
$value.Length -ge 2 -and
(
($value.StartsWith('"') -and $value.EndsWith('"')) -or
($value.StartsWith("'") -and $value.EndsWith("'"))
)
) {
$value = $value.Substring(1, $value.Length - 2)
}
$existing = [Environment]::GetEnvironmentVariable($name)
if ([string]::IsNullOrWhiteSpace($existing)) {
[Environment]::SetEnvironmentVariable(
$name,
$value,
[EnvironmentVariableTarget]::Process
)
}
}
}
function Get-AppcastVersion {
param([Parameter(Mandatory = $true)][string]$Path)
[xml]$xml = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$enclosure = $xml.rss.channel.item.enclosure
if ($null -eq $enclosure) {
throw "AppCast does not contain channel/item/enclosure: $Path"
}
return $enclosure.GetAttribute('version', $script:SparkleNamespace)
}
function Write-Appcast {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$BuildVersion,
[Parameter(Mandatory = $true)][string]$Notes,
[Parameter(Mandatory = $true)][string]$InstallerUrl,
[Parameter(Mandatory = $true)][string]$Signature,
[Parameter(Mandatory = $true)][long]$Length
)
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($false)
$settings.Indent = $true
$settings.IndentChars = ' '
$settings.NewLineChars = "`n"
$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
$writer = [System.Xml.XmlWriter]::Create($Path, $settings)
try {
$writer.WriteStartDocument()
$writer.WriteStartElement('rss')
$writer.WriteAttributeString('version', '2.0')
$writer.WriteAttributeString(
'xmlns',
'sparkle',
'http://www.w3.org/2000/xmlns/',
$script:SparkleNamespace
)
$writer.WriteStartElement('channel')
$writer.WriteElementString('title', 'MoonWell Launcher')
$writer.WriteElementString(
'description',
'MoonWell Launcher updates'
)
$writer.WriteElementString('language', 'ru')
$writer.WriteStartElement('item')
$writer.WriteElementString(
'title',
"MoonWell Launcher $Version"
)
$writer.WriteElementString('description', $Notes)
$writer.WriteElementString(
'pubDate',
[DateTimeOffset]::Now.ToString(
'ddd, dd MMM yyyy HH:mm:ss zzz',
[Globalization.CultureInfo]::InvariantCulture
)
)
$writer.WriteStartElement('enclosure')
$writer.WriteAttributeString('url', $InstallerUrl)
$writer.WriteAttributeString(
'sparkle',
'version',
$script:SparkleNamespace,
$BuildVersion
)
$writer.WriteAttributeString(
'sparkle',
'shortVersionString',
$script:SparkleNamespace,
$Version
)
$writer.WriteAttributeString(
'sparkle',
'os',
$script:SparkleNamespace,
'windows'
)
$writer.WriteAttributeString(
'sparkle',
'dsaSignature',
$script:SparkleNamespace,
$Signature
)
$writer.WriteAttributeString('length', $Length.ToString())
$writer.WriteAttributeString(
'type',
'application/octet-stream'
)
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndDocument()
}
finally {
$writer.Dispose()
}
}
function Assert-SigningKeyPair {
param(
[Parameter(Mandatory = $true)][string]$OpenSsl,
[Parameter(Mandatory = $true)][string]$PrivateKey,
[Parameter(Mandatory = $true)][string]$PublicKey,
[Parameter(Mandatory = $true)][string]$TemporaryPublicKey
)
Invoke-RequiredCommand -Command $OpenSsl -Arguments @(
'dsa',
'-in',
$PrivateKey,
'-pubout',
'-out',
$TemporaryPublicKey
)
$expected = (
Get-Content -LiteralPath $PublicKey -Raw
) -replace '\s', ''
$actual = (
Get-Content -LiteralPath $TemporaryPublicKey -Raw
) -replace '\s', ''
if ($expected -cne $actual) {
throw 'The private DSA key does not match the launcher public key.'
}
}
function Assert-InstallerSignature {
param(
[Parameter(Mandatory = $true)][string]$OpenSsl,
[Parameter(Mandatory = $true)][string]$InstallerPath,
[Parameter(Mandatory = $true)][string]$PublicKey,
[Parameter(Mandatory = $true)][string]$Signature,
[Parameter(Mandatory = $true)][string]$TemporaryDirectory
)
$signaturePath = Join-Path $TemporaryDirectory 'installer.sig'
$firstDigestPath = Join-Path $TemporaryDirectory 'installer.sha1'
[IO.File]::WriteAllBytes(
$signaturePath,
[Convert]::FromBase64String($Signature)
)
$stream = [IO.File]::OpenRead($InstallerPath)
try {
$sha1 = [Security.Cryptography.SHA1]::Create()
try {
[IO.File]::WriteAllBytes(
$firstDigestPath,
$sha1.ComputeHash($stream)
)
}
finally {
$sha1.Dispose()
}
}
finally {
$stream.Dispose()
}
Invoke-RequiredCommand -Command $OpenSsl -Arguments @(
'dgst',
'-sha1',
'-verify',
$PublicKey,
'-signature',
$signaturePath,
$firstDigestPath
)
}
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$previousLocation = Get-Location
try {
Set-Location $repositoryRoot
$resolvedEnvFile = if ([IO.Path]::IsPathRooted($EnvFile)) {
$EnvFile
}
else {
Join-Path $repositoryRoot $EnvFile
}
Import-DotEnv -Path $resolvedEnvFile
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
$S3Endpoint = $env:AWS_ENDPOINT
}
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
$S3Endpoint = 'https://storage.yandexcloud.net'
}
if ([string]::IsNullOrWhiteSpace($S3Bucket)) {
$S3Bucket = $env:AWS_BUCKET
}
if ([string]::IsNullOrWhiteSpace($S3Bucket)) {
$S3Bucket = 'warcraft-client'
}
foreach ($url in @($ApiBaseUrl, $AppcastUrl, $S3Endpoint)) {
$uri = $null
if (
-not [Uri]::TryCreate(
$url,
[UriKind]::Absolute,
[ref]$uri
) -or
$uri.Scheme -ne 'https'
) {
throw "Production URL must be absolute HTTPS: $url"
}
}
$python = $null
if (-not $DryRun) {
foreach ($name in @(
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'LAUNCHER_AUTH_KEY'
)) {
$value = [Environment]::GetEnvironmentVariable($name)
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Required environment variable $name is not set."
}
}
$python = Resolve-RequiredCommand `
-Command 'python' `
-Description 'Python'
Invoke-RequiredCommand -Command $python -Arguments @(
'-c',
'import boto3'
)
}
$versionMatch = [regex]::Match(
(Get-Content -LiteralPath 'pubspec.yaml' -Raw),
'(?m)^version:\s*(\d+\.\d+\.\d+)\+(\d+)\s*$'
)
if (-not $versionMatch.Success) {
throw 'The pubspec.yaml version must use the X.Y.Z+N format.'
}
$version = $versionMatch.Groups[1].Value
$buildNumber = $versionMatch.Groups[2].Value
if (
-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and
$ExpectedVersion -ne $version
) {
throw "Expected version $ExpectedVersion, pubspec.yaml contains $version."
}
$privateKey = (Resolve-Path -LiteralPath $PrivateKeyPath).Path
$publicKey = (Resolve-Path -LiteralPath $PublicKeyPath).Path
$flutter = Resolve-RequiredCommand `
-Command $FlutterCommand `
-Description 'Flutter SDK'
$dart = Resolve-RequiredCommand `
-Command $DartCommand `
-Description 'Dart SDK'
if ([string]::IsNullOrWhiteSpace($InnoSetupCommand)) {
$InnoSetupCommand =
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe'
}
$innoSetup = Resolve-RequiredCommand `
-Command $InnoSetupCommand `
-Description 'Inno Setup Compiler'
if ([string]::IsNullOrWhiteSpace($OpenSslCommand)) {
$gitOpenSsl = 'C:\Program Files\Git\usr\bin\openssl.exe'
$OpenSslCommand = if (Test-Path -LiteralPath $gitOpenSsl) {
$gitOpenSsl
}
else {
'openssl'
}
}
$openSsl = Resolve-RequiredCommand `
-Command $OpenSslCommand `
-Description 'OpenSSL'
$temporaryDirectory = Join-Path $repositoryRoot 'build\launcher_release'
New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null
Write-Step "Checking DSA keys for version $version+$buildNumber"
Assert-SigningKeyPair `
-OpenSsl $openSsl `
-PrivateKey $privateKey `
-PublicKey $publicKey `
-TemporaryPublicKey (
Join-Path $temporaryDirectory 'derived_dsa_pub.pem'
)
if (-not $DryRun -and -not $Force) {
Write-Step 'Checking the published version'
try {
$publishedPath = Join-Path $temporaryDirectory 'current_appcast.xml'
Invoke-WebRequest `
-Uri $AppcastUrl `
-UseBasicParsing `
-OutFile $publishedPath
$publishedVersion = Get-AppcastVersion -Path $publishedPath
$publishedReleaseVersion = $publishedVersion.Split('+')[0]
if (
-not [string]::IsNullOrWhiteSpace($publishedVersion) -and
[version]$version -le [version]$publishedReleaseVersion
) {
throw (
"Version $version is not newer than published version " +
"$publishedVersion. Increase X.Y.Z or use -Force."
)
}
}
catch {
if ($_.Exception.Message -like 'Version *') {
throw
}
Write-Warning (
'Could not determine the currently published version: ' +
$_.Exception.Message
)
}
}
if (-not $SkipChecks) {
Write-Step 'Dependencies, formatting, analysis, and tests'
Invoke-RequiredCommand -Command $flutter -Arguments @('pub', 'get')
Invoke-RequiredCommand -Command $dart -Arguments @(
'format',
'--output=none',
'--set-exit-if-changed',
'lib',
'test'
)
Invoke-RequiredCommand -Command $flutter -Arguments @(
'analyze',
'lib',
'test'
)
Invoke-RequiredCommand -Command $flutter -Arguments @('test')
}
Write-Step 'Building the Windows production application'
Invoke-RequiredCommand -Command $flutter -Arguments @(
'build',
'windows',
'--release',
"--dart-define=MOONWELL_API_BASE_URL=$ApiBaseUrl",
"--dart-define=MOONWELL_APPCAST_URL=$AppcastUrl"
)
Write-Step 'Building the Inno Setup installer'
Invoke-RequiredCommand -Command $innoSetup -Arguments @(
"/DMyAppVersion=$version",
'installer\moonwell_launcher.iss'
)
$installerPath = Join-Path (
Join-Path $repositoryRoot 'build\installer'
) "moonwell_launcher_${version}_windows_setup.exe"
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
throw "The installer was not created: $installerPath"
}
Write-Step 'Signing the installer with DSA'
$gitOpenSslDirectory = Split-Path $openSsl -Parent
$previousPath = $env:PATH
try {
$env:PATH = "$gitOpenSslDirectory;$env:PATH"
$signOutput = & $dart run auto_updater:sign_update `
$installerPath `
$privateKey 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Could not sign the installer: $signOutput"
}
}
finally {
$env:PATH = $previousPath
}
$signatureMatch = [regex]::Match(
($signOutput -join "`n"),
'sparkle:dsaSignature="([^"]+)"',
[Text.RegularExpressions.RegexOptions]::Singleline
)
if (-not $signatureMatch.Success) {
throw 'The signing tool did not return sparkle:dsaSignature.'
}
$signature = $signatureMatch.Groups[1].Value -replace '\s', ''
[void][Convert]::FromBase64String($signature)
$installer = Get-Item -LiteralPath $installerPath
$installerSha256 = Get-FileSha256 -Path $installerPath
Assert-InstallerSignature `
-OpenSsl $openSsl `
-InstallerPath $installerPath `
-PublicKey $publicKey `
-Signature $signature `
-TemporaryDirectory $temporaryDirectory
$installerUrl = (
$S3Endpoint.TrimEnd('/') +
'/' +
$S3Bucket +
'/' +
$S3Key
)
$appcastBuildVersion = "$version+$buildNumber"
$appcastPath = if ($DryRun) {
Join-Path $temporaryDirectory 'appcast.xml'
}
else {
Join-Path $repositoryRoot 'appcast.xml'
}
Write-Step 'Generating appcast.xml'
Write-Appcast `
-Path $appcastPath `
-Version $version `
-BuildVersion $appcastBuildVersion `
-Notes $ReleaseNotes `
-InstallerUrl $installerUrl `
-Signature $signature `
-Length $installer.Length
[xml]$generatedAppcast = Get-Content `
-LiteralPath $appcastPath `
-Raw `
-Encoding UTF8
if ((Get-AppcastVersion -Path $appcastPath) -ne $appcastBuildVersion) {
throw 'The generated AppCast contains the wrong version.'
}
if ($DryRun) {
Write-Step 'Dry run complete - production was not changed'
Write-Host "Version: $version+$buildNumber"
Write-Host "Installer: $installerPath"
Write-Host "Size: $($installer.Length)"
Write-Host "SHA-256: $installerSha256"
Write-Host "AppCast: $appcastPath"
return
}
Write-Step "Uploading $S3Bucket/$S3Key"
$env:MOONWELL_RELEASE_FILE = $installerPath
$env:MOONWELL_RELEASE_ENDPOINT = $S3Endpoint
$env:MOONWELL_RELEASE_BUCKET = $S3Bucket
$env:MOONWELL_RELEASE_KEY = $S3Key
$env:MOONWELL_RELEASE_VERSION = $version
$env:MOONWELL_RELEASE_SHA256 = $installerSha256
$uploadHelper = Join-Path `
$repositoryRoot `
'tool\upload_launcher_release.py'
if (-not (Test-Path -LiteralPath $uploadHelper -PathType Leaf)) {
throw "S3 upload helper was not found: $uploadHelper"
}
try {
Invoke-RequiredCommand -Command $python -Arguments @($uploadHelper)
}
finally {
Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_ENDPOINT -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_BUCKET -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_KEY -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_SHA256 -ErrorAction SilentlyContinue
}
Write-Step 'Verifying the uploaded installer'
$downloadedInstaller = Join-Path `
$temporaryDirectory `
'published_installer.exe'
Invoke-WebRequest `
-Uri $installerUrl `
-UseBasicParsing `
-OutFile $downloadedInstaller
$publishedSha256 = Get-FileSha256 -Path $downloadedInstaller
if ($publishedSha256 -ne $installerSha256) {
throw (
'The uploaded installer SHA-256 does not match the local file: ' +
"$publishedSha256"
)
}
Write-Step 'Publishing AppCast'
$publishResponse = Invoke-WebRequest `
-Uri "$($ApiBaseUrl.TrimEnd('/'))/api/service/update-appcast" `
-Method Post `
-Headers @{
Auth = $env:LAUNCHER_AUTH_KEY
Accept = 'application/json'
} `
-ContentType 'application/xml' `
-Body ([IO.File]::ReadAllBytes($appcastPath)) `
-UseBasicParsing
if ($publishResponse.StatusCode -lt 200 -or $publishResponse.StatusCode -ge 300) {
throw "AppCast publication returned HTTP $($publishResponse.StatusCode)."
}
Write-Step 'Verifying the published AppCast'
$publishedAppcastPath = Join-Path `
$temporaryDirectory `
'published_appcast.xml'
$publicResponse = Invoke-WebRequest `
-Uri $AppcastUrl `
-UseBasicParsing `
-OutFile $publishedAppcastPath `
-PassThru
if ($publicResponse.StatusCode -ne 200) {
throw "The public AppCast returned HTTP $($publicResponse.StatusCode)."
}
if (
(Get-FileSha256 -Path $publishedAppcastPath) -ne
(Get-FileSha256 -Path $appcastPath)
) {
throw 'The published AppCast does not match the local file.'
}
[xml]$publishedAppcast = Get-Content `
-LiteralPath $publishedAppcastPath `
-Raw `
-Encoding UTF8
$publishedEnclosure = $publishedAppcast.rss.channel.item.enclosure
if (
$publishedEnclosure.GetAttribute(
'version',
$script:SparkleNamespace
) -ne $appcastBuildVersion -or
[long]$publishedEnclosure.length -ne $installer.Length -or
$publishedEnclosure.url -ne $installerUrl
) {
throw 'The published AppCast contains incorrect release data.'
}
Write-Step 'Production deployment complete'
Write-Host "Version: $version+$buildNumber"
Write-Host "Installer: $installerUrl"
Write-Host "SHA-256: $installerSha256"
Write-Host "AppCast: $AppcastUrl"
}
finally {
Set-Location $previousLocation
}